Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96bb048228 | |||
| 88c006764a | |||
| 9c3dace12f | |||
| ec53363fdb | |||
| 0e260ede73 | |||
| 60902167c0 | |||
| 592e7865c5 | |||
| f082c295fd | |||
| 10b981ad1d | |||
| 2058e453ff | |||
| b776b6b8f1 | |||
| fe3787f0cd | |||
| 7ea1fc2d69 | |||
| 71cc183efb | |||
| 7873f8b94f | |||
| b34b40794d | |||
| ed310f0d8d | |||
| 2db173cd56 | |||
| d032103b7d | |||
| bcc488993d | |||
| aa0a97c245 | |||
| a39d4d4406 | |||
| 7c2417f5a9 | |||
| ea66e6c9c7 |
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
smoke-and-integration:
|
smoke-and-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-scripts:
|
lint-scripts:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * 1' # Weekly on Monday
|
- cron: '0 9 * * 1' # Weekly on Monday
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
secrets-scan:
|
secrets-scan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ htmlcov/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db.back
|
*.db.back
|
||||||
|
*.db.back-*
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
"""App-level settings (status checker interval, etc.)."""
|
"""App-level settings (status checker interval, etc.)."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class AppSettings(BaseModel):
|
class AppSettings(BaseModel):
|
||||||
interval_seconds: int
|
interval_seconds: int
|
||||||
|
service_check_enabled: bool = False
|
||||||
|
service_check_interval: int = Field(default=300, ge=30)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppSettings)
|
@router.get("", response_model=AppSettings)
|
||||||
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
||||||
return AppSettings(interval_seconds=settings.status_checker_interval)
|
return AppSettings(
|
||||||
|
interval_seconds=settings.status_checker_interval,
|
||||||
|
service_check_enabled=settings.service_check_enabled,
|
||||||
|
service_check_interval=settings.service_check_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AppSettings)
|
@router.post("", response_model=AppSettings)
|
||||||
@@ -23,7 +30,13 @@ async def update_settings(
|
|||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
try:
|
try:
|
||||||
settings.status_checker_interval = payload.interval_seconds
|
settings.status_checker_interval = payload.interval_seconds
|
||||||
|
settings.service_check_enabled = payload.service_check_enabled
|
||||||
|
settings.service_check_interval = payload.service_check_interval
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
|
# Apply the service-check schedule live.
|
||||||
|
set_service_checks_enabled(payload.service_check_enabled)
|
||||||
|
if payload.service_check_enabled:
|
||||||
|
reschedule_service_checks(payload.service_check_interval)
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
@@ -10,6 +11,12 @@ router = APIRouter()
|
|||||||
_connections: list[WebSocket] = []
|
_connections: list[WebSocket] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _drop(websocket: WebSocket) -> None:
|
||||||
|
"""Remove a connection if still present — idempotent, never raises."""
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
_connections.remove(websocket)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
# Accept first so we can send a close frame with a reason code
|
# Accept first so we can send a close frame with a reason code
|
||||||
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
|
|||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
_connections.remove(websocket)
|
pass
|
||||||
|
finally:
|
||||||
|
# Any error (disconnect or otherwise) must release the slot, else the
|
||||||
|
# dead socket lingers in the broadcast pool.
|
||||||
|
_drop(websocket)
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast(payload: str) -> None:
|
async def _broadcast(payload: str) -> None:
|
||||||
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
|
|||||||
try:
|
try:
|
||||||
await conn.send_text(payload)
|
await conn.send_text(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
_connections.remove(conn)
|
_drop(conn)
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
||||||
@@ -54,6 +65,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
||||||
|
await _broadcast(json.dumps({
|
||||||
|
"type": "service_status",
|
||||||
|
"node_id": node_id,
|
||||||
|
"services": services,
|
||||||
|
"checked_at": checked_at,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
||||||
await _broadcast(json.dumps({
|
await _broadcast(json.dumps({
|
||||||
"type": "scan_device_found",
|
"type": "scan_device_found",
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class Settings(BaseSettings):
|
|||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
|
# Per-service status checker (independent of node checks). Off by default.
|
||||||
|
service_check_enabled: bool = False
|
||||||
|
service_check_interval: int = 300
|
||||||
|
|
||||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||||
# Used by the MCP server to authenticate against the backend without a user password.
|
# Used by the MCP server to authenticate against the backend without a user password.
|
||||||
# Leave empty to disable MCP service key auth.
|
# Leave empty to disable MCP service key auth.
|
||||||
@@ -77,6 +81,10 @@ class Settings(BaseSettings):
|
|||||||
self.scanner_ranges = data["scanner_ranges"]
|
self.scanner_ranges = data["scanner_ranges"]
|
||||||
if "status_checker_interval" in data:
|
if "status_checker_interval" in data:
|
||||||
self.status_checker_interval = int(data["status_checker_interval"])
|
self.status_checker_interval = int(data["status_checker_interval"])
|
||||||
|
if "service_check_enabled" in data:
|
||||||
|
self.service_check_enabled = bool(data["service_check_enabled"])
|
||||||
|
if "service_check_interval" in data:
|
||||||
|
self.service_check_interval = int(data["service_check_interval"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -86,6 +94,8 @@ class Settings(BaseSettings):
|
|||||||
self._override_path().write_text(json.dumps({
|
self._override_path().write_text(json.dumps({
|
||||||
"scanner_ranges": self.scanner_ranges,
|
"scanner_ranges": self.scanner_ranges,
|
||||||
"status_checker_interval": self.status_checker_interval,
|
"status_checker_interval": self.status_checker_interval,
|
||||||
|
"service_check_enabled": self.service_check_enabled,
|
||||||
|
"service_check_interval": self.service_check_interval,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal
|
from app.db.database import AsyncSessionLocal
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
from app.services.status_checker import check_node
|
from app.services.status_checker import check_node, check_services
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||||
|
"""Pick the address to probe services on: first IP, else hostname."""
|
||||||
|
if ip:
|
||||||
|
first = ip.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
return hostname or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_service_checks() -> None:
|
||||||
|
"""Check every service of every node and broadcast per-service results."""
|
||||||
|
if not settings.service_check_enabled:
|
||||||
|
return
|
||||||
|
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Node))
|
||||||
|
nodes = result.scalars().all()
|
||||||
|
checkable = [
|
||||||
|
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||||
|
for n in nodes
|
||||||
|
if n.services
|
||||||
|
]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
for node_id, host, services in checkable:
|
||||||
|
try:
|
||||||
|
statuses = await check_services(host, services)
|
||||||
|
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_service_check_job() -> None:
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_service_checks,
|
||||||
|
"interval",
|
||||||
|
seconds=settings.service_check_interval,
|
||||||
|
id="service_checks",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
if settings.service_check_enabled:
|
||||||
|
_add_service_check_job()
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
|
|||||||
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def reschedule_service_checks(interval_seconds: int) -> None:
|
||||||
|
"""Update the service-check interval on the running scheduler (if enabled)."""
|
||||||
|
if interval_seconds < 30:
|
||||||
|
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
|
||||||
|
if not scheduler.running:
|
||||||
|
logger.warning("Scheduler not running, skipping reschedule")
|
||||||
|
return
|
||||||
|
if scheduler.get_job("service_checks"):
|
||||||
|
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
|
||||||
|
logger.info("Service checks rescheduled to every %ds", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def set_service_checks_enabled(enabled: bool) -> None:
|
||||||
|
"""Add or remove the service-check job on the running scheduler."""
|
||||||
|
if not scheduler.running:
|
||||||
|
return
|
||||||
|
job = scheduler.get_job("service_checks")
|
||||||
|
if enabled and not job:
|
||||||
|
_add_service_check_job()
|
||||||
|
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
|
||||||
|
elif not enabled and job:
|
||||||
|
scheduler.remove_job("service_checks")
|
||||||
|
logger.info("Service checks disabled")
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|||||||
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
return {"status": "offline", "response_time_ms": None}
|
return {"status": "offline", "response_time_ms": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ipv6(host: str) -> bool:
|
||||||
|
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
||||||
|
try:
|
||||||
|
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
# ping(8) -W flag units differ by OS:
|
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
||||||
# Linux: seconds (-W 1 = 1s)
|
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
||||||
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
|
#
|
||||||
# Windows: -w in ms (-w 1000 = 1s)
|
# -W flag units differ by OS:
|
||||||
|
# Linux: seconds (-W 2 = 2s)
|
||||||
|
# macOS: milliseconds (-W 2000 = 2s)
|
||||||
|
# Windows: -w in ms (-w 2000 = 2s)
|
||||||
|
#
|
||||||
|
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
||||||
|
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
||||||
|
ipv6 = _is_ipv6(host)
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
family = ["-6"] if ipv6 else ["-4"]
|
||||||
|
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
||||||
elif sys.platform == "darwin":
|
elif sys.platform == "darwin":
|
||||||
args = ["ping", "-c", "1", "-W", "1000", host]
|
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
||||||
else:
|
else:
|
||||||
args = ["ping", "-c", "1", "-W", "1", host]
|
family = ["-6"] if ipv6 else []
|
||||||
|
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
@@ -100,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
except (TimeoutError, OSError, socket.gaierror):
|
except (TimeoutError, OSError, socket.gaierror):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Per-service status checks ---
|
||||||
|
|
||||||
|
# Ports that are not HTTP/web. These get NO status check — a service here stays
|
||||||
|
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
|
||||||
|
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
|
||||||
|
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
|
||||||
|
_NON_HTTP_PORTS = frozenset({
|
||||||
|
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
||||||
|
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||||
|
})
|
||||||
|
_HTTPS_PORTS = frozenset({443, 8443})
|
||||||
|
|
||||||
|
|
||||||
|
def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||||
|
"""Bracket bare IPv6 literals for use in a URL."""
|
||||||
|
return f"[{host}]" if _is_ipv6(host) else host
|
||||||
|
|
||||||
|
|
||||||
|
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||||
|
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||||
|
|
||||||
|
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
|
||||||
|
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
|
||||||
|
so it keeps its category colour instead of flashing red. An open TCP socket
|
||||||
|
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
||||||
|
"""
|
||||||
|
if not host or host.startswith("-"):
|
||||||
|
return "unknown"
|
||||||
|
if str(svc.get("protocol", "")).lower() == "udp":
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
port = svc.get("port")
|
||||||
|
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||||
|
|
||||||
|
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
||||||
|
if port is not None and port in _NON_HTTP_PORTS:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
name = str(svc.get("service_name", "")).lower()
|
||||||
|
is_web = port is not None or "http" in name
|
||||||
|
if not is_web:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheme = "https" if (
|
||||||
|
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||||
|
) else "http"
|
||||||
|
url_host = _service_host(svc, host)
|
||||||
|
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||||
|
return "online" if await _http_get(url, verify=False) else "offline"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
||||||
|
return "offline"
|
||||||
|
|
||||||
|
|
||||||
|
async def check_services(
|
||||||
|
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Check every service against host concurrently (bounded).
|
||||||
|
|
||||||
|
Returns a list of {port, protocol, status} dicts, one per input service.
|
||||||
|
"""
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
|
||||||
|
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
status = await check_service(svc, host)
|
||||||
|
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||||
|
|
||||||
|
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pyyaml==6.0.2
|
|||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.149.7
|
zeroconf==0.149.12
|
||||||
aiomqtt==2.3.0
|
aiomqtt==2.3.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
|
from app.core.scheduler import (
|
||||||
|
_run_service_checks,
|
||||||
|
_run_status_checks,
|
||||||
|
set_service_checks_enabled,
|
||||||
|
start_scheduler,
|
||||||
|
stop_scheduler,
|
||||||
|
)
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
|
|
||||||
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
|
|||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
mock_settings.status_checker_interval = 45
|
mock_settings.status_checker_interval = 45
|
||||||
|
mock_settings.service_check_enabled = False
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
_, kwargs = mock_sched.add_job.call_args
|
_, kwargs = mock_sched.add_job.call_args
|
||||||
assert kwargs["seconds"] == 45
|
assert kwargs["seconds"] == 45
|
||||||
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
|
|||||||
mock_sched.add_job.assert_called_once()
|
mock_sched.add_job.assert_called_once()
|
||||||
mock_sched.start.assert_called_once()
|
mock_sched.start.assert_called_once()
|
||||||
mock_sched.shutdown.assert_called_once()
|
mock_sched.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
|
||||||
|
mock_settings.service_check_enabled = False
|
||||||
|
await _run_service_checks()
|
||||||
|
mock_cs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
node = _make_node(
|
||||||
|
ip="10.0.0.5",
|
||||||
|
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||||
|
)
|
||||||
|
session.add(node)
|
||||||
|
await session.commit()
|
||||||
|
node_id = node.id
|
||||||
|
|
||||||
|
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
|
||||||
|
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
await _run_service_checks()
|
||||||
|
|
||||||
|
mock_bcast.assert_awaited_once()
|
||||||
|
_, kwargs = mock_bcast.call_args
|
||||||
|
assert kwargs["node_id"] == node_id
|
||||||
|
assert kwargs["services"] == statuses
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_skips_nodes_without_services(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_node(ip="10.0.0.6", services=[]))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
await _run_service_checks()
|
||||||
|
mock_cs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_service_checks_enabled_adds_and_removes_job():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.service_check_interval = 300
|
||||||
|
# Enable: no existing job -> add
|
||||||
|
mock_sched.get_job.return_value = None
|
||||||
|
set_service_checks_enabled(True)
|
||||||
|
mock_sched.add_job.assert_called_once()
|
||||||
|
# Disable: existing job -> remove
|
||||||
|
mock_sched.get_job.return_value = MagicMock()
|
||||||
|
set_service_checks_enabled(False)
|
||||||
|
mock_sched.remove_job.assert_called_once_with("service_checks")
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_scheduler_adds_service_job_when_enabled():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
|
mock_settings.status_checker_interval = 60
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
mock_settings.service_check_interval = 300
|
||||||
|
start_scheduler()
|
||||||
|
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||||
|
assert "status_checks" in job_ids
|
||||||
|
assert "service_checks" in job_ids
|
||||||
|
|||||||
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
|||||||
async def test_update_settings_requires_auth(client: AsyncClient):
|
async def test_update_settings_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
|
||||||
|
res = await client.get("/api/v1/settings", headers=headers)
|
||||||
|
data = res.json()
|
||||||
|
assert "service_check_enabled" in data
|
||||||
|
assert "service_check_interval" in data
|
||||||
|
assert isinstance(data["service_check_enabled"], bool)
|
||||||
|
assert isinstance(data["service_check_interval"], int)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
|
||||||
|
with patch("app.api.routes.settings.settings") as mock_settings:
|
||||||
|
mock_settings.save_overrides = lambda: None
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={
|
||||||
|
"interval_seconds": 60,
|
||||||
|
"service_check_enabled": True,
|
||||||
|
"service_check_interval": 600,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["service_check_enabled"] is True
|
||||||
|
assert body["service_check_interval"] == 600
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
|
from app.api.routes.status import (
|
||||||
|
_connections,
|
||||||
|
_drop,
|
||||||
|
broadcast_scan_update,
|
||||||
|
broadcast_service_status,
|
||||||
|
broadcast_status,
|
||||||
|
)
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
|
|||||||
assert len(_connections) == 0
|
assert len(_connections) == 0
|
||||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
await broadcast_scan_update(run_id="r", devices_found=0)
|
await broadcast_scan_update(run_id="r", devices_found=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# broadcast_service_status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_service_status_payload():
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
class FakeWS:
|
||||||
|
async def send_text(self, text: str) -> None:
|
||||||
|
received.append(text)
|
||||||
|
|
||||||
|
fake = FakeWS()
|
||||||
|
_connections.append(fake)
|
||||||
|
try:
|
||||||
|
await broadcast_service_status(
|
||||||
|
node_id="node-7",
|
||||||
|
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
|
||||||
|
checked_at="2024-01-01T00:00:00",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_drop(fake)
|
||||||
|
|
||||||
|
msg = json.loads(received[0])
|
||||||
|
assert msg["type"] == "service_status"
|
||||||
|
assert msg["node_id"] == "node-7"
|
||||||
|
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _drop — idempotent connection removal (regression for double-remove crash)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_drop_is_idempotent():
|
||||||
|
"""Dropping a connection twice must not raise (was a ValueError crash)."""
|
||||||
|
class FakeWS:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = FakeWS()
|
||||||
|
_connections.append(fake)
|
||||||
|
_drop(fake)
|
||||||
|
_drop(fake) # second drop must be a no-op
|
||||||
|
assert fake not in _connections
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_dead_connection_dropped_once_safely():
|
||||||
|
"""A send failure removes the dead socket without a double-remove crash."""
|
||||||
|
class DeadWS:
|
||||||
|
async def send_text(self, _: str) -> None:
|
||||||
|
raise RuntimeError("disconnected")
|
||||||
|
|
||||||
|
dead = DeadWS()
|
||||||
|
_connections.append(dead)
|
||||||
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
|
# A second broadcast must not raise even though dead is already gone.
|
||||||
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
|
assert dead not in _connections
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import _ping, _tcp_connect, check_node
|
from app.services.status_checker import (
|
||||||
|
_ping,
|
||||||
|
_tcp_connect,
|
||||||
|
check_node,
|
||||||
|
check_service,
|
||||||
|
check_services,
|
||||||
|
)
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -169,9 +175,14 @@ async def test_ping_uses_unix_args_on_non_windows():
|
|||||||
assert "-c" in captured["args"]
|
assert "-c" in captured["args"]
|
||||||
assert "-W" in captured["args"]
|
assert "-W" in captured["args"]
|
||||||
assert "-n" not in captured["args"]
|
assert "-n" not in captured["args"]
|
||||||
# Linux: -W is in seconds; 1s is the intended timeout
|
# 2 probes so a single dropped packet doesn't flap the node offline
|
||||||
|
c_idx = captured["args"].index("-c")
|
||||||
|
assert captured["args"][c_idx + 1] == "2"
|
||||||
|
# Linux: -W is in seconds; 2s is the intended timeout
|
||||||
w_idx = captured["args"].index("-W")
|
w_idx = captured["args"].index("-W")
|
||||||
assert captured["args"][w_idx + 1] == "1"
|
assert captured["args"][w_idx + 1] == "2"
|
||||||
|
# IPv4 target → no -6 flag
|
||||||
|
assert "-6" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -193,7 +204,7 @@ async def test_ping_uses_macos_millisecond_timeout():
|
|||||||
assert "-c" in captured["args"]
|
assert "-c" in captured["args"]
|
||||||
assert "-W" in captured["args"]
|
assert "-W" in captured["args"]
|
||||||
w_idx = captured["args"].index("-W")
|
w_idx = captured["args"].index("-W")
|
||||||
assert captured["args"][w_idx + 1] == "1000"
|
assert captured["args"][w_idx + 1] == "2000"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -216,6 +227,75 @@ async def test_ping_uses_windows_args_on_win32():
|
|||||||
assert "-c" not in captured["args"]
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- _ping IPv6 support ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_linux_uses_dash6():
|
||||||
|
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("fe80::1")
|
||||||
|
|
||||||
|
assert "-6" in captured["args"]
|
||||||
|
assert captured["args"][-1] == "fe80::1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_macos_uses_ping6():
|
||||||
|
"""macOS ships a separate ping6 binary for IPv6 targets."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("2001:db8::1")
|
||||||
|
|
||||||
|
assert captured["args"][0] == "ping6"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_windows_uses_dash6():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("2001:db8::1")
|
||||||
|
|
||||||
|
assert "-6" in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_ipv6_detection():
|
||||||
|
from app.services.status_checker import _is_ipv6
|
||||||
|
|
||||||
|
assert _is_ipv6("fe80::1") is True
|
||||||
|
assert _is_ipv6("2001:db8::1") is True
|
||||||
|
assert _is_ipv6("[2001:db8::1]") is True
|
||||||
|
assert _is_ipv6("192.168.1.1") is False
|
||||||
|
assert _is_ipv6("example.local") is False
|
||||||
|
|
||||||
|
|
||||||
# --- check_node target validation ---
|
# --- check_node target validation ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -268,3 +348,117 @@ async def test_tcp_connect_os_error():
|
|||||||
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
||||||
result = await _tcp_connect("192.168.1.1", 9999)
|
result = await _tcp_connect("192.168.1.1", 9999)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- check_service ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_no_host_is_unknown():
|
||||||
|
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_flag_host_is_unknown():
|
||||||
|
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_udp_is_unknown():
|
||||||
|
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_portless_non_web_is_unknown():
|
||||||
|
svc = {"protocol": "tcp", "service_name": "thing"}
|
||||||
|
assert await check_service(svc, "10.0.0.1") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_web_uses_http_get():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "online"
|
||||||
|
assert captured["url"] == "http://10.0.0.1:8080"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_https_port_uses_https_scheme():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
await check_service(svc, "10.0.0.1")
|
||||||
|
assert captured["url"].startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_web_offline_when_http_fails():
|
||||||
|
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
|
||||||
|
assert await check_service(svc, "10.0.0.1") == "offline"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_non_http_port_is_unknown():
|
||||||
|
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
|
||||||
|
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
||||||
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
||||||
|
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "unknown"
|
||||||
|
mock_tcp.assert_not_called()
|
||||||
|
mock_http.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_ssh_port_22_is_unknown():
|
||||||
|
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
||||||
|
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||||
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "unknown"
|
||||||
|
mock_tcp.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_ipv6_brackets_url_host():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
await check_service(svc, "2001:db8::1")
|
||||||
|
assert captured["url"] == "http://[2001:db8::1]:80"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_services_returns_status_per_service():
|
||||||
|
services = [
|
||||||
|
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
||||||
|
]
|
||||||
|
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||||
|
results = await check_services("10.0.0.1", services)
|
||||||
|
assert results == [
|
||||||
|
{"port": 80, "protocol": "tcp", "status": "online"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_services_empty_list():
|
||||||
|
assert await check_services("10.0.0.1", []) == []
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -6196,9 +6196,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.18",
|
"version": "4.12.25",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -37,6 +37,9 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"hono": "^4.12.21"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
|||||||
+42
-9
@@ -27,7 +27,9 @@ import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
|||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
|
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
|
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
@@ -42,7 +44,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -52,7 +54,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||||
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||||
@@ -68,6 +70,8 @@ export default function App() {
|
|||||||
const [addTextOpen, setAddTextOpen] = useState(false)
|
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
|
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||||
|
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
@@ -607,7 +611,7 @@ export default function App() {
|
|||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
forceView={sidebarForceView}
|
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||||
onOpenPending={openPendingModal}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
@@ -631,6 +635,8 @@ export default function App() {
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
|
onRequestAddToGroup={setPendingGroupAdd}
|
||||||
|
onRequestAddToContainer={setPendingContainerAdd}
|
||||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,7 +651,7 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -672,7 +678,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return nodes
|
return nodes
|
||||||
.filter((n) => !descendants.has(n.id))
|
.filter((n) => !descendants.has(n.id))
|
||||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||||
})()}
|
})()}
|
||||||
currentNodeId={editNodeId ?? undefined}
|
currentNodeId={editNodeId ?? undefined}
|
||||||
/>
|
/>
|
||||||
@@ -706,8 +712,6 @@ export default function App() {
|
|||||||
onClose={() => setScanConfigOpen(false)}
|
onClose={() => setScanConfigOpen(false)}
|
||||||
onScanNow={() => {
|
onScanNow={() => {
|
||||||
toast.success('Network scan started — check Scan History for results')
|
toast.success('Network scan started — check Scan History for results')
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => setSidebarForceView('history'), 0)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -718,12 +722,18 @@ export default function App() {
|
|||||||
onClose={() => setZigbeeImportOpen(false)}
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
onAddToCanvas={handleZigbeeAddToCanvas}
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||||
onPendingImported={() => {
|
onPendingImported={() => {
|
||||||
setSidebarForceView(undefined)
|
toast.success('Zigbee import started — check Scan History for results')
|
||||||
setTimeout(() => setSidebarForceView('history'), 0)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ScanHistoryModal
|
||||||
|
open={scanHistoryOpen}
|
||||||
|
onClose={() => setScanHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<GroupRectModal
|
<GroupRectModal
|
||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
@@ -804,6 +814,29 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
|
<ConfirmAddToGroupModal
|
||||||
|
open={!!pendingGroupAdd}
|
||||||
|
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
|
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
|
||||||
|
setPendingGroupAdd(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingGroupAdd(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmAddToGroupModal
|
||||||
|
open={!!pendingContainerAdd}
|
||||||
|
variant="container"
|
||||||
|
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
|
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
|
||||||
|
setPendingContainerAdd(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingContainerAdd(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ describe('api/client', () => {
|
|||||||
it('settingsApi get/save', () => {
|
it('settingsApi get/save', () => {
|
||||||
mod.settingsApi.get()
|
mod.settingsApi.get()
|
||||||
expect(api.get).toHaveBeenCalledWith('/settings')
|
expect(api.get).toHaveBeenCalledWith('/settings')
|
||||||
mod.settingsApi.save({ interval_seconds: 30 })
|
mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
||||||
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
|
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
||||||
|
|||||||
@@ -90,9 +90,15 @@ export const scanApi = {
|
|||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
interval_seconds: number
|
||||||
|
service_check_enabled: boolean
|
||||||
|
service_check_interval: number
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
get: () => api.get<AppSettings>('/settings'),
|
||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const designsApi = {
|
export const designsApi = {
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ interface CanvasContainerProps {
|
|||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
|
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||||
|
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||||
onOpenPending?: (deviceId: string) => void
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
@@ -42,7 +44,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
fitViewPending, clearFitViewPending,
|
fitViewPending, clearFitViewPending,
|
||||||
copySelectedNodes, pasteNodes,
|
copySelectedNodes, pasteNodes,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||||
|
|
||||||
// Track the last cursor position over the canvas so paste lands under it.
|
// Track the last cursor position over the canvas so paste lands under it.
|
||||||
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
@@ -125,6 +127,24 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
|
|
||||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
|
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||||
|
// before the alignment snap so detection uses the dropped position.
|
||||||
|
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||||
|
if (dragNode && !dragNode.parentId &&
|
||||||
|
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||||
|
const intersecting = getIntersectingNodes(dragNode)
|
||||||
|
const group = intersecting.find((n) => n.data.type === 'group')
|
||||||
|
if (group) {
|
||||||
|
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||||
|
} else {
|
||||||
|
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||||
|
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||||
|
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodeDragStop(event, dragNode, dragNodes)
|
||||||
|
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -139,7 +159,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
onNodeDrag={onNodeDrag}
|
onNodeDrag={onNodeDrag}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={handleNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore', () => ({
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record<string, string> }) => unknown) =>
|
||||||
|
sel({ hideIp: false, serviceStatuses: {} }),
|
||||||
|
serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/themes', () => ({
|
vi.mock('@/utils/themes', () => ({
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
// Capture props passed to ReactFlow so we can test the callbacks
|
// Capture props passed to ReactFlow so we can test the callbacks
|
||||||
let rfProps: Record<string, unknown> = {}
|
let rfProps: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
// Hoisted holder so the mock factory can read the configurable intersection set.
|
||||||
|
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
|
||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
ReactFlow: (props: Record<string, unknown>) => {
|
ReactFlow: (props: Record<string, unknown>) => {
|
||||||
rfProps = props
|
rfProps = props
|
||||||
@@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
SelectionMode: { Partial: 'partial' },
|
SelectionMode: { Partial: 'partial' },
|
||||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({
|
||||||
|
fitView: vi.fn(),
|
||||||
|
screenToFlowPosition: vi.fn(),
|
||||||
|
getIntersectingNodes: () => rf.intersecting,
|
||||||
|
setNodes: vi.fn(),
|
||||||
|
getNodes: () => [],
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -42,6 +51,7 @@ function makeEdge(id: string): Edge<EdgeData> {
|
|||||||
describe('CanvasContainer', () => {
|
describe('CanvasContainer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rfProps = {}
|
rfProps = {}
|
||||||
|
rf.intersecting = []
|
||||||
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||||
useThemeStore.setState({ activeTheme: 'default' })
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
})
|
})
|
||||||
@@ -154,6 +164,93 @@ describe('CanvasContainer', () => {
|
|||||||
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Drag onto group → onRequestAddToGroup ─────────────────────────────────
|
||||||
|
|
||||||
|
function groupNode(id: string): Node<NodeData> {
|
||||||
|
return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires onRequestAddToGroup when a node is dropped over a group', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
const group = groupNode('g1')
|
||||||
|
rf.intersecting = [group]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup when no group is under the node', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [makeNode('n2')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup for an already-parented node', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = { ...makeNode('n1'), parentId: 'gOther' }
|
||||||
|
rf.intersecting = [groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = groupNode('g2')
|
||||||
|
rf.intersecting = [groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Drag onto container node → onRequestAddToContainer ────────────────────
|
||||||
|
|
||||||
|
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
|
||||||
|
return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers a group over a container when both intersect', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1'), groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer for an already-parented node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = { ...makeNode('n1'), parentId: 'pxOther' }
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [makeNode('n2')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('enables snapToGrid', () => {
|
it('enables snapToGrid', () => {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #183 — connection labels must support multiple lines.
|
||||||
|
*
|
||||||
|
* The label is a free-text string; newlines entered in the EdgeModal textarea
|
||||||
|
* are stored verbatim. The rendered label div must preserve those newlines
|
||||||
|
* (`whitespace-pre-line`) instead of collapsing them into a single line.
|
||||||
|
*
|
||||||
|
* <EdgeLabelRenderer> normally portals into a node that only exists inside a
|
||||||
|
* full <ReactFlow> host, so we stub it to a passthrough to render the label
|
||||||
|
* markup directly.
|
||||||
|
*/
|
||||||
|
vi.mock('@xyflow/react', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@xyflow/react')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { HomelableEdge } = await import('../index')
|
||||||
|
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected: false,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge label', () => {
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const { getByText } = renderEdge({ label: 'uplink' })
|
||||||
|
expect(getByText('uplink')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves newlines in the rendered label (issue #183)', () => {
|
||||||
|
const { container } = renderEdge({ label: 'line one\nline two' })
|
||||||
|
const label = Array.from(container.querySelectorAll('div.whitespace-pre-line')).find((d) =>
|
||||||
|
d.textContent === 'line one\nline two',
|
||||||
|
)
|
||||||
|
expect(label).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no label div when label is empty', () => {
|
||||||
|
const { container } = renderEdge({ label: undefined })
|
||||||
|
expect(container.querySelector('div.whitespace-pre-line')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -406,7 +406,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
{data?.label && (
|
{data?.label && (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded whitespace-pre-line text-center"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { NodeIcon } from '@/components/ui/NodeIcon'
|
|||||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||||
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
@@ -31,6 +31,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
||||||
@@ -151,6 +152,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||||
{services.map((svc, idx) => {
|
{services.map((svc, idx) => {
|
||||||
const url = getServiceUrl(svc, serviceHost)
|
const url = getServiceUrl(svc, serviceHost)
|
||||||
|
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
|
||||||
const row = (
|
const row = (
|
||||||
<div
|
<div
|
||||||
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||||
@@ -164,7 +166,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
{/* LEFT: service name */}
|
{/* LEFT: service name */}
|
||||||
<span
|
<span
|
||||||
className="font-medium truncate"
|
className="font-medium truncate"
|
||||||
style={{ minWidth: 0 }}
|
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||||
title={svc.service_name}
|
title={svc.service_name}
|
||||||
>
|
>
|
||||||
{svc.service_name}
|
{svc.service_name}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Layers } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ConfirmAddToGroupModalProps {
|
||||||
|
open: boolean
|
||||||
|
nodeLabel: string
|
||||||
|
/** Label of the destination group/container. */
|
||||||
|
targetLabel: string
|
||||||
|
/** Destination kind — drives the wording. Defaults to 'group'. */
|
||||||
|
variant?: 'group' | 'container'
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmAddToGroupModal({
|
||||||
|
open,
|
||||||
|
nodeLabel,
|
||||||
|
targetLabel,
|
||||||
|
variant = 'group',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmAddToGroupModalProps) {
|
||||||
|
const action = variant === 'container' ? 'Add to container' : 'Add to group'
|
||||||
|
const noun = variant === 'container' ? 'container' : 'group'
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Layers size={16} className="text-[#00d4ff]" />
|
||||||
|
{action}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
|
||||||
|
<span className="font-medium text-foreground">{targetLabel}</span>?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { RotateCcw } from 'lucide-react'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
||||||
@@ -44,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
type,
|
||||||
label: label || undefined,
|
label: label.trim() || undefined,
|
||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
@@ -96,12 +97,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label>
|
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
|
||||||
<Input
|
<Textarea
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="e.g. 1G, trunk..."
|
placeholder={'e.g. 1G, trunk...\nsecond line'}
|
||||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
rows={2}
|
||||||
|
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ interface ParentCandidate {
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
type: NodeType
|
type: NodeType
|
||||||
|
/** True when the node has container mode on, so any node can nest inside it. */
|
||||||
|
container_mode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
@@ -98,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const selectedType = (form.type ?? 'generic') as NodeType
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||||
const validParentTypes = getValidParentTypes(selectedType)
|
const validParentTypes = getValidParentTypes(selectedType)
|
||||||
|
// A parent is valid either by the type rules (lxc/vm/docker_container) or
|
||||||
|
// because the candidate is a container-mode node (any child can nest in it).
|
||||||
|
const isValidParent = (p: ParentCandidate) =>
|
||||||
|
validParentTypes.includes(p.type) || p.container_mode === true
|
||||||
let safeParentId = form.parent_id
|
let safeParentId = form.parent_id
|
||||||
if (validParentTypes.length === 0) {
|
if (safeParentId) {
|
||||||
safeParentId = undefined
|
|
||||||
} else if (safeParentId) {
|
|
||||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||||
}
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
@@ -130,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
const next: Partial<NodeData> = { ...f, type: t }
|
const next: Partial<NodeData> = { ...f, type: t }
|
||||||
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
||||||
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
|
// Drop the parent only if it's no longer a valid target for the
|
||||||
|
// new type — keep container-mode parents (any node can nest).
|
||||||
|
const parent = parentCandidates.find((n) => n.id === f.parent_id)
|
||||||
|
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
|
||||||
|
next.parent_id = undefined
|
||||||
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
@@ -349,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{(() => {
|
{(() => {
|
||||||
const childType = (form.type ?? 'generic') as NodeType
|
const childType = (form.type ?? 'generic') as NodeType
|
||||||
const validParentTypes = getValidParentTypes(childType)
|
const validParentTypes = getValidParentTypes(childType)
|
||||||
if (validParentTypes.length === 0) return null
|
// Candidates: type-based parents (lxc/vm/docker_container) plus any
|
||||||
|
// container-mode node. The current parent is always kept so an
|
||||||
|
// already-nested node can be re-targeted or detached here.
|
||||||
const validParents = parentCandidates.filter(
|
const validParents = parentCandidates.filter(
|
||||||
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
(n) => n.id !== currentNodeId &&
|
||||||
|
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
|
||||||
)
|
)
|
||||||
if (validParents.length === 0) return null
|
if (validParents.length === 0) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
|
||||||
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export interface ScanRun {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind?: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanHistoryModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type KindFilter = 'all' | 'ip' | 'zigbee'
|
||||||
|
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'running', label: 'Running' },
|
||||||
|
{ key: 'done', label: 'Done' },
|
||||||
|
{ key: 'error', label: 'Error' },
|
||||||
|
{ key: 'cancelled', label: 'Cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'ip', label: 'IP' },
|
||||||
|
{ key: 'zigbee', label: 'Zigbee' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusColor(s: string): string {
|
||||||
|
return s === 'done' ? '#39d353'
|
||||||
|
: s === 'running' ? '#e3b341'
|
||||||
|
: s === 'error' ? '#f85149'
|
||||||
|
: s === 'cancelled' ? '#8b949e'
|
||||||
|
: '#8b949e'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUtc(ts: string): number {
|
||||||
|
return new Date(ts.endsWith('Z') ? ts : ts + 'Z').getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 0) ms = 0
|
||||||
|
const s = Math.floor(ms / 1000)
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const rem = s % 60
|
||||||
|
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}h ${m % 60}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDuration(r: ScanRun, now: number): string {
|
||||||
|
const start = parseUtc(r.started_at)
|
||||||
|
const end = r.finished_at ? parseUtc(r.finished_at) : now
|
||||||
|
return formatDuration(end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||||
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [stopping, setStopping] = useState<string | null>(null)
|
||||||
|
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const prevRunsRef = useRef<ScanRun[]>([])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await scanApi.runs()
|
||||||
|
const next: ScanRun[] = res.data
|
||||||
|
|
||||||
|
// Surface transitions and refresh dependent UI
|
||||||
|
for (const run of next) {
|
||||||
|
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||||
|
if (prev?.status === 'running' && run.status === 'error') {
|
||||||
|
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||||
|
}
|
||||||
|
if (prev?.status === 'running' && run.status === 'done') {
|
||||||
|
if (run.kind === 'zigbee') {
|
||||||
|
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
useCanvasStore.getState().notifyScanDeviceFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevRunsRef.current = next
|
||||||
|
setRuns(next)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load scan history')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
prevRunsRef.current = []
|
||||||
|
load()
|
||||||
|
}, [open, load])
|
||||||
|
|
||||||
|
// Auto-refresh every 3s while any run is still running (only when open)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const hasRunning = runs.some((r) => r.status === 'running')
|
||||||
|
if (!hasRunning) return
|
||||||
|
const id = setInterval(load, 3000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, runs, load])
|
||||||
|
|
||||||
|
// Tick the clock every second while a scan is running (for live elapsed duration)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const hasRunning = runs.some((r) => r.status === 'running')
|
||||||
|
if (!hasRunning) return
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, runs])
|
||||||
|
|
||||||
|
const handleStop = async (runId: string) => {
|
||||||
|
setStopping(runId)
|
||||||
|
try {
|
||||||
|
await scanApi.stop(runId)
|
||||||
|
toast.success('Scan stop requested')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to stop scan')
|
||||||
|
} finally {
|
||||||
|
setStopping(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = runs.filter((r) => {
|
||||||
|
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
|
||||||
|
if (kindFilter !== 'all' && k !== kindFilter) return false
|
||||||
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!max-w-none w-[90vw] max-w-2xl h-[80vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Clock size={16} className="text-[#00d4ff]" />
|
||||||
|
Scan History
|
||||||
|
<span className="text-muted-foreground font-normal text-xs">
|
||||||
|
({filtered.length}{filtered.length !== runs.length && ` of ${runs.length}`})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
|
||||||
|
</button>
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Type</span>
|
||||||
|
{KIND_FILTERS.map((f) => (
|
||||||
|
<FilterChip key={f.key} active={kindFilter === f.key} onClick={() => setKindFilter(f.key)}>
|
||||||
|
{f.label}
|
||||||
|
</FilterChip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Status</span>
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<FilterChip key={f.key} active={statusFilter === f.key} onClick={() => setStatusFilter(f.key)}>
|
||||||
|
{f.label}
|
||||||
|
</FilterChip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-2">
|
||||||
|
{loading && runs.length === 0 && (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
|
||||||
|
<Inbox size={28} className="opacity-50" />
|
||||||
|
<p className="text-sm">{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const isZigbee = r.kind === 'zigbee'
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||||
|
<span className="font-mono text-sm text-foreground capitalize">{r.status}</span>
|
||||||
|
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={isZigbee
|
||||||
|
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||||
|
: { background: '#a855f722', color: '#a855f7' }}
|
||||||
|
>
|
||||||
|
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
|
||||||
|
{isZigbee ? 'Zigbee' : 'IP'}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||||
|
{r.devices_found} found
|
||||||
|
</span>
|
||||||
|
{r.status === 'running' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<button
|
||||||
|
aria-label="Stop scan"
|
||||||
|
onClick={() => handleStop(r.id)}
|
||||||
|
disabled={stopping === r.id}
|
||||||
|
className="p-1 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{stopping === r.id
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: <StopCircle size={13} />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Stop scan</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta grid */}
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
|
||||||
|
<Meta label="Started" value={new Date(parseUtc(r.started_at)).toLocaleString()} />
|
||||||
|
<Meta
|
||||||
|
label="Finished"
|
||||||
|
value={r.finished_at ? new Date(parseUtc(r.finished_at)).toLocaleString() : '—'}
|
||||||
|
/>
|
||||||
|
<Meta label="Duration" value={runDuration(r, now)} mono />
|
||||||
|
<Meta label="Devices" value={`${r.devices_found}`} mono />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.ranges.length > 0 && (
|
||||||
|
<div className="mt-2 text-[11px]">
|
||||||
|
<span className="text-muted-foreground">Ranges: </span>
|
||||||
|
<span className="text-[#8b949e] font-mono break-all">{r.ranges.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.error && (
|
||||||
|
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
|
||||||
|
{r.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors cursor-pointer ${
|
||||||
|
active
|
||||||
|
? 'bg-[#00d4ff]/10 border-[#00d4ff]/40 text-[#00d4ff]'
|
||||||
|
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span>
|
||||||
|
<span className={`text-foreground ${mono ? 'font-mono' : ''}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ interface SettingsModalProps {
|
|||||||
|
|
||||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
|
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
||||||
|
const [serviceInterval, setServiceInterval] = useState(300)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
@@ -28,7 +30,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || STANDALONE) return
|
if (!open || STANDALONE) return
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
.then((res) => setIntervalValue(res.data.interval_seconds))
|
.then((res) => {
|
||||||
|
setIntervalValue(res.data.interval_seconds)
|
||||||
|
setServiceCheckEnabled(res.data.service_check_enabled)
|
||||||
|
setServiceInterval(res.data.service_check_interval)
|
||||||
|
})
|
||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
@@ -49,7 +55,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await settingsApi.save({ interval_seconds: interval })
|
await settingsApi.save({
|
||||||
|
interval_seconds: interval,
|
||||||
|
service_check_enabled: serviceCheckEnabled,
|
||||||
|
service_check_interval: serviceInterval,
|
||||||
|
})
|
||||||
toast.success('Settings saved')
|
toast.success('Settings saved')
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -85,6 +95,36 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
How often node health is polled (ping, HTTP, SSH…)
|
How often node health is polled (ping, HTTP, SSH…)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
|
||||||
|
<span className="text-xs text-foreground">Check services individually</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={serviceCheckEnabled}
|
||||||
|
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle per-service status checks"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
max={3600}
|
||||||
|
value={serviceInterval}
|
||||||
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(v) }}
|
||||||
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
aria-label="Service check interval"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Probes each service port. Offline services turn red. Default 300s (5 min).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
|
||||||
|
|
||||||
|
describe('ConfirmAddToGroupModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows node and group labels when open', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Router')).toBeDefined()
|
||||||
|
expect(screen.getByText('DMZ')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm when the confirm button is clicked', () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onCancel when the cancel button is clicked', () => {
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses container wording when variant is container', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
|
||||||
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -47,11 +47,32 @@ describe('EdgeModal', () => {
|
|||||||
it('calls onSubmit with label when filled', () => {
|
it('calls onSubmit with label when filled', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'uplink' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
|
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves newlines in label so it can span multiple lines (issue #183)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'line one\nline two' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].label).toBe('line one\nline two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims surrounding whitespace/blank lines from label on submit', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: ' a\nb\n\n' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].label).toBe('a\nb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the label field as a textarea (multi-line input)', () => {
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByPlaceholderText(/e\.g\. 1G, trunk/).tagName).toBe('TEXTAREA')
|
||||||
|
})
|
||||||
|
|
||||||
it('omits label from payload when empty', () => {
|
it('omits label from payload when empty', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
@@ -171,7 +192,7 @@ describe('EdgeModal', () => {
|
|||||||
|
|
||||||
it('pre-fills label from initial prop', () => {
|
it('pre-fills label from initial prop', () => {
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
|
||||||
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
|
const input = screen.getByPlaceholderText(/e\.g\. 1G, trunk/) as HTMLTextAreaElement
|
||||||
expect(input.value).toBe('trunk')
|
expect(input.value).toBe('trunk')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,48 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for a plain node when a container-mode candidate exists', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'server' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still hides Parent Container for a plain node when the candidate is not in container mode', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'server' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for an already-nested plain node so it can be detached', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a container-mode parent_id on submit for a plain node', () => {
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('px1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a parent_id that is not a valid container on submit', () => {
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Appearance ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||||
|
|||||||
+72
-69
@@ -1,21 +1,17 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
import { Sidebar } from '../Sidebar'
|
import { ScanHistoryModal } from '../ScanHistoryModal'
|
||||||
import * as canvasStore from '@/stores/canvasStore'
|
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
|
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
|
||||||
|
}))
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
trigger: vi.fn(),
|
|
||||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
|
||||||
},
|
},
|
||||||
settingsApi: { get: vi.fn(), save: vi.fn() },
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
@@ -24,9 +20,10 @@ import { toast } from 'sonner'
|
|||||||
const RUNNING_RUN = {
|
const RUNNING_RUN = {
|
||||||
id: 'run-1',
|
id: 'run-1',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
kind: 'ip',
|
||||||
ranges: ['192.168.1.0/24'],
|
ranges: ['192.168.1.0/24'],
|
||||||
devices_found: 2,
|
devices_found: 2,
|
||||||
started_at: new Date().toISOString(),
|
started_at: new Date(Date.now() - 5000).toISOString(),
|
||||||
finished_at: null,
|
finished_at: null,
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
@@ -34,16 +31,18 @@ const RUNNING_RUN = {
|
|||||||
const DONE_RUN = {
|
const DONE_RUN = {
|
||||||
id: 'run-2',
|
id: 'run-2',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
|
kind: 'ip',
|
||||||
ranges: ['192.168.1.0/24'],
|
ranges: ['192.168.1.0/24'],
|
||||||
devices_found: 3,
|
devices_found: 3,
|
||||||
started_at: new Date().toISOString(),
|
started_at: new Date(Date.now() - 60000).toISOString(),
|
||||||
finished_at: new Date().toISOString(),
|
finished_at: new Date(Date.now() - 30000).toISOString(),
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const CANCELLED_RUN = {
|
const CANCELLED_RUN = {
|
||||||
id: 'run-3',
|
id: 'run-3',
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
|
kind: 'ip',
|
||||||
ranges: ['192.168.1.0/24'],
|
ranges: ['192.168.1.0/24'],
|
||||||
devices_found: 1,
|
devices_found: 1,
|
||||||
started_at: new Date().toISOString(),
|
started_at: new Date().toISOString(),
|
||||||
@@ -51,36 +50,26 @@ const CANCELLED_RUN = {
|
|||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSidebar() {
|
const ZIGBEE_RUN = {
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
id: 'run-4',
|
||||||
nodes: [],
|
status: 'done',
|
||||||
hasUnsavedChanges: false,
|
kind: 'zigbee',
|
||||||
hideIp: false,
|
ranges: [],
|
||||||
toggleHideIp: vi.fn(),
|
devices_found: 7,
|
||||||
addNode: vi.fn(),
|
started_at: new Date().toISOString(),
|
||||||
scanEventTs: 0,
|
finished_at: new Date().toISOString(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal() {
|
||||||
return render(
|
return render(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Sidebar
|
<ScanHistoryModal open onClose={vi.fn()} />
|
||||||
onAddNode={vi.fn()}
|
|
||||||
onAddGroupRect={vi.fn()}
|
|
||||||
onScan={vi.fn()}
|
|
||||||
onSave={vi.fn()}
|
|
||||||
onNodeApproved={vi.fn()}
|
|
||||||
/>
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openHistory() {
|
describe('ScanHistoryModal', () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
|
|
||||||
// Wait for runs to load
|
|
||||||
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ScanHistoryPanel — stop scan', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
@@ -88,68 +77,82 @@ describe('ScanHistoryPanel — stop scan', () => {
|
|||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads runs when opened', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
|
||||||
|
expect(await screen.findByText('done')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no scans', async () => {
|
||||||
|
renderModal()
|
||||||
|
expect(await screen.findByText('No scans yet')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('shows stop button only for running scans', async () => {
|
it('shows stop button only for running scans', async () => {
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||||
renderSidebar()
|
renderModal()
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
|
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Stop scan' })).toHaveLength(1)
|
||||||
// Exactly one stop button rendered (for the running scan only)
|
|
||||||
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
|
|
||||||
expect(stopButtons).toHaveLength(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls scanApi.stop with the correct run ID on click', async () => {
|
it('calls scanApi.stop with the correct run ID', async () => {
|
||||||
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
renderSidebar()
|
renderModal()
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
fireEvent.click(stopBtn)
|
fireEvent.click(stopBtn)
|
||||||
|
await waitFor(() => expect(scanApi.stop).toHaveBeenCalledWith('run-1'))
|
||||||
await waitFor(() => {
|
|
||||||
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows success toast when stop succeeds', async () => {
|
it('shows success toast when stop succeeds', async () => {
|
||||||
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
renderSidebar()
|
renderModal()
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
fireEvent.click(stopBtn)
|
fireEvent.click(stopBtn)
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Scan stop requested'))
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error toast when stop fails', async () => {
|
it('shows error toast when stop fails', async () => {
|
||||||
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
|
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
renderSidebar()
|
renderModal()
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
fireEvent.click(stopBtn)
|
fireEvent.click(stopBtn)
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to stop scan'))
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders cancelled status without stop button or spinner', async () => {
|
it('renders cancelled status without a stop button', async () => {
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
|
||||||
renderSidebar()
|
renderModal()
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
|
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
|
||||||
|
|
||||||
// No stop button
|
|
||||||
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows duration for a finished run', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
// DONE_RUN ran 30s
|
||||||
|
expect(await screen.findByText('30s')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by status', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getByText('done')).toBeDefined())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Running' }))
|
||||||
|
expect(screen.queryByText('done')).toBeNull()
|
||||||
|
expect(screen.getByText('running')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by kind', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZIGBEE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||||
|
// Only the zigbee run (7 found) remains
|
||||||
|
expect(screen.getByText('7 found')).toBeDefined()
|
||||||
|
expect(screen.queryByText('3 found')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -17,8 +17,8 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
describe('SettingsModal', () => {
|
describe('SettingsModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||||
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
})
|
})
|
||||||
@@ -47,7 +47,7 @@ describe('SettingsModal', () => {
|
|||||||
fireEvent.change(input, { target: { value: '180' } })
|
fireEvent.change(input, { target: { value: '180' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
|
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180, service_check_enabled: false, service_check_interval: 300 })
|
||||||
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -76,6 +76,22 @@ describe('SettingsModal', () => {
|
|||||||
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads and toggles the per-service check setting, saving its interval', async () => {
|
||||||
|
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: true, service_check_interval: 600 } } as never)
|
||||||
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
|
const toggle = await screen.findByLabelText('Toggle per-service status checks') as HTMLInputElement
|
||||||
|
// The toggle label renders immediately; its checked state only flips once
|
||||||
|
// settingsApi.get() resolves, so wait for that before asserting.
|
||||||
|
await waitFor(() => expect(toggle.checked).toBe(true))
|
||||||
|
expect(await screen.findByDisplayValue('600')).toBeDefined()
|
||||||
|
|
||||||
|
fireEvent.click(toggle) // disable
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 60, service_check_enabled: false, service_check_interval: 600 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('calls onClose on Cancel', async () => {
|
it('calls onClose on Cancel', async () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SettingsModal open onClose={onClose} />)
|
render(<SettingsModal open onClose={onClose} />)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createElement, useState } from 'react'
|
import { createElement, useRef, useState } from 'react'
|
||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
import { splitIps } from '@/utils/maskIp'
|
import { splitIps } from '@/utils/maskIp'
|
||||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
@@ -21,7 +21,8 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool
|
|||||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||||
|
|
||||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup } = useCanvasStore()
|
||||||
|
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||||
|
|
||||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||||
@@ -64,6 +65,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
node={node}
|
node={node}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
onUngroup={() => { ungroup(node.id) }}
|
onUngroup={() => { ungroup(node.id) }}
|
||||||
|
onRemoveChild={(id) => { snapshotHistory(); removeFromGroup(node.id, id) }}
|
||||||
|
onChangeDescription={(value) => updateNode(node.id, { notes: value })}
|
||||||
|
onSnapshotBeforeEdit={snapshotHistory}
|
||||||
onToggleBorder={() => {
|
onToggleBorder={() => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
updateNode(node.id, {
|
updateNode(node.id, {
|
||||||
@@ -314,7 +318,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
editingIndex === i ? (
|
editingIndex === i ? (
|
||||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||||
) : (
|
) : (
|
||||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -425,17 +429,33 @@ interface GroupDetailPanelProps {
|
|||||||
node: Node<NodeData>
|
node: Node<NodeData>
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
onUngroup: () => void
|
onUngroup: () => void
|
||||||
|
onRemoveChild: (id: string) => void
|
||||||
|
onChangeDescription: (value: string) => void
|
||||||
|
onSnapshotBeforeEdit: () => void
|
||||||
onToggleBorder: () => void
|
onToggleBorder: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelectChild: (id: string) => void
|
onSelectChild: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
|
function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onSnapshotBeforeEdit, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
|
||||||
const children = nodes.filter((n) => n.parentId === node.id)
|
const children = nodes.filter((n) => n.parentId === node.id)
|
||||||
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
||||||
const showBorder = node.data.custom_colors?.show_border !== false
|
const showBorder = node.data.custom_colors?.show_border !== false
|
||||||
|
|
||||||
|
// Description reuses data.notes, which already round-trips to the backend.
|
||||||
|
// Controlled + committed on every keystroke so ANY save path (incl. Ctrl+S,
|
||||||
|
// which never blurs the field) captures it. History is snapshotted once at the
|
||||||
|
// start of an edit session so the whole edit is a single undo step.
|
||||||
|
const snappedRef = useRef(false)
|
||||||
|
const handleDescriptionChange = (value: string) => {
|
||||||
|
if (!snappedRef.current) {
|
||||||
|
onSnapshotBeforeEdit()
|
||||||
|
snappedRef.current = true
|
||||||
|
}
|
||||||
|
onChangeDescription(value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleUngroup = () => {
|
const handleUngroup = () => {
|
||||||
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
|
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
|
||||||
onUngroup()
|
onUngroup()
|
||||||
@@ -461,20 +481,48 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
|
|||||||
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount} offline</span>}
|
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount} offline</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="px-4 py-3 border-b border-border">
|
||||||
|
<label htmlFor="group-description" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="group-description"
|
||||||
|
value={node.data.notes ?? ''}
|
||||||
|
onFocus={() => { snappedRef.current = false }}
|
||||||
|
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||||
|
placeholder="Add a description for this group…"
|
||||||
|
rows={3}
|
||||||
|
className="mt-1.5 w-full resize-y rounded-md bg-[#21262d] border border-[#30363d] px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-[#00d4ff]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Children list */}
|
{/* Children list */}
|
||||||
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
|
||||||
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
|
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<button
|
<div
|
||||||
key={child.id}
|
key={child.id}
|
||||||
|
className="group/member w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors"
|
||||||
|
>
|
||||||
|
<button
|
||||||
onClick={() => onSelectChild(child.id)}
|
onClick={() => onSelectChild(child.id)}
|
||||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
|
className="flex items-center gap-2 min-w-0 flex-1 text-left cursor-pointer"
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
||||||
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
||||||
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveChild(child.id)}
|
||||||
|
aria-label={`Remove ${child.data.label} from group`}
|
||||||
|
title="Remove from group"
|
||||||
|
className="shrink-0 opacity-0 group-hover/member:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -673,9 +721,13 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
// Manually-added services carry no category, so they fell back to grey even
|
||||||
|
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
|
||||||
|
const categoryColor = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e')
|
||||||
|
// A live offline service overrides the category colour with red.
|
||||||
|
const color = status === 'offline' ? '#f85149' : categoryColor
|
||||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { designsApi, scanApi } from '@/api/client'
|
import { designsApi } from '@/api/client'
|
||||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||||
import type { Design } from '@/types'
|
import type { Design } from '@/types'
|
||||||
@@ -14,24 +14,11 @@ import { useLatestRelease } from '@/hooks/useLatestRelease'
|
|||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'history'
|
|
||||||
|
|
||||||
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||||
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||||
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ScanRun {
|
|
||||||
id: string
|
|
||||||
status: string
|
|
||||||
kind?: string
|
|
||||||
ranges: string[]
|
|
||||||
devices_found: number
|
|
||||||
started_at: string
|
|
||||||
finished_at: string | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
@@ -40,14 +27,12 @@ interface SidebarProps {
|
|||||||
onZigbeeImport: () => void
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
forceView?: SidebarView
|
onOpenHistory: () => void
|
||||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, forceView, onOpenPending }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||||
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
||||||
@@ -81,15 +66,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
}
|
}
|
||||||
}, [designs.length, removeDesign])
|
}, [designs.length, removeDesign])
|
||||||
|
|
||||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
|
||||||
if (forceView !== prevForceView) {
|
|
||||||
setPrevForceView(forceView)
|
|
||||||
if (forceView) {
|
|
||||||
setActiveView(forceView)
|
|
||||||
setCollapsed(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
@@ -193,8 +169,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
label="Canvas"
|
label="Canvas"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === 'canvas'}
|
active
|
||||||
onClick={() => setActiveView('canvas')}
|
|
||||||
/>
|
/>
|
||||||
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
@@ -210,23 +185,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
icon={Clock}
|
icon={Clock}
|
||||||
label="Scan History"
|
label="Scan History"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === 'history'}
|
onClick={onOpenHistory}
|
||||||
onClick={() => setActiveView('history')}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* View content (only when expanded) */}
|
{!collapsed && <div className="flex-1" />}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats (only on canvas view) */}
|
|
||||||
{!collapsed && activeView === 'canvas' && (
|
|
||||||
<div className="flex-1" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats footer */}
|
{/* Stats footer */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
@@ -294,136 +258,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function ScanHistoryPanel() {
|
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const prevRunsRef = useRef<ScanRun[]>([])
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.runs()
|
|
||||||
const next: ScanRun[] = res.data
|
|
||||||
|
|
||||||
// Surface transitions and refresh dependent UI
|
|
||||||
for (const run of next) {
|
|
||||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
|
||||||
if (prev?.status === 'running' && run.status === 'error') {
|
|
||||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
|
||||||
}
|
|
||||||
if (prev?.status === 'running' && run.status === 'done') {
|
|
||||||
if (run.kind === 'zigbee') {
|
|
||||||
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
|
||||||
}
|
|
||||||
// Notify pending modal/canvas to refresh
|
|
||||||
useCanvasStore.getState().notifyScanDeviceFound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevRunsRef.current = next
|
|
||||||
setRuns(next)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load scan history')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
// Auto-refresh every 3s while any run is still running
|
|
||||||
useEffect(() => {
|
|
||||||
const hasRunning = runs.some((r) => r.status === 'running')
|
|
||||||
if (!hasRunning) return
|
|
||||||
const id = setInterval(load, 3000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}, [runs, load])
|
|
||||||
|
|
||||||
const [stopping, setStopping] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleStop = async (runId: string) => {
|
|
||||||
setStopping(runId)
|
|
||||||
try {
|
|
||||||
await scanApi.stop(runId)
|
|
||||||
toast.success('Scan stop requested')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to stop scan')
|
|
||||||
} finally {
|
|
||||||
setStopping(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColor = (s: string) =>
|
|
||||||
s === 'done' ? '#39d353'
|
|
||||||
: s === 'running' ? '#e3b341'
|
|
||||||
: s === 'error' ? '#f85149'
|
|
||||||
: s === 'cancelled' ? '#8b949e'
|
|
||||||
: '#8b949e'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</span>
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{loading && runs.length === 0 && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && runs.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
|
|
||||||
)}
|
|
||||||
{runs.map((r) => (
|
|
||||||
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
|
||||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
|
||||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
|
||||||
<span
|
|
||||||
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
|
|
||||||
style={r.kind === 'zigbee'
|
|
||||||
? { background: '#00d4ff22', color: '#00d4ff' }
|
|
||||||
: { background: '#a855f722', color: '#a855f7' }}
|
|
||||||
>
|
|
||||||
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
|
||||||
{r.status === 'running' && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<button
|
|
||||||
aria-label="Stop scan"
|
|
||||||
onClick={() => handleStop(r.id)}
|
|
||||||
disabled={stopping === r.id}
|
|
||||||
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{stopping === r.id
|
|
||||||
? <Loader2 size={11} className="animate-spin" />
|
|
||||||
: <StopCircle size={11} />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">Stop scan</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
|
||||||
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
|
|
||||||
</div>
|
|
||||||
{r.ranges.length > 0 && (
|
|
||||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
|
||||||
)}
|
|
||||||
{r.error && (
|
|
||||||
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
|
|
||||||
{r.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function VersionBadge() {
|
function VersionBadge() {
|
||||||
const current = __APP_VERSION__
|
const current = __APP_VERSION__
|
||||||
const { latest, hasUpdate } = useLatestRelease(current)
|
const { latest, hasUpdate } = useLatestRelease(current)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import * as canvasStore from '@/stores/canvasStore'
|
|||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore', async (importActual) => ({
|
||||||
|
...(await importActual<typeof canvasStore>()),
|
||||||
|
useCanvasStore: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||||
return {
|
return {
|
||||||
@@ -22,8 +25,8 @@ function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStore(nodeData: Partial<NodeData> = {}) {
|
function setupStore(nodeData: Partial<NodeData> = {}, serviceStatuses: Record<string, string> = {}) {
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
const state = {
|
||||||
nodes: [makeNode(nodeData)],
|
nodes: [makeNode(nodeData)],
|
||||||
selectedNodeId: 'n1',
|
selectedNodeId: 'n1',
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
@@ -33,7 +36,12 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
|
|||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
createGroup: vi.fn(),
|
createGroup: vi.fn(),
|
||||||
ungroup: vi.fn(),
|
ungroup: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
serviceStatuses,
|
||||||
|
}
|
||||||
|
// Support both the bare destructure call and the selector-based call.
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||||
|
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('DetailPanel', () => {
|
describe('DetailPanel', () => {
|
||||||
@@ -502,6 +510,43 @@ describe('DetailPanel', () => {
|
|||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('health').tagName).not.toBe('A')
|
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('colors a categoryless but reachable web service blue, not grey', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const link = screen.getByRole('link', { name: 'nginx' })
|
||||||
|
expect(link.style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a categoryless unreachable service grey', () => {
|
||||||
|
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('health').style.color).toBe('rgb(139, 148, 158)') // #8b949e
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects an explicit category over the url fallback', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10', services: [{ port: 5432, protocol: 'tcp', service_name: 'pg', category: 'database', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paints a service red when its live status is offline', () => {
|
||||||
|
setupStore(
|
||||||
|
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||||
|
{ 'n1:8080/tcp': 'offline' },
|
||||||
|
)
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the category colour when the live status is online', () => {
|
||||||
|
setupStore(
|
||||||
|
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||||
|
{ 'n1:8080/tcp': 'online' },
|
||||||
|
)
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Last Seen formatting', () => {
|
describe('Last Seen formatting', () => {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const mockStore = {
|
|||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
createGroup: vi.fn(),
|
createGroup: vi.fn(),
|
||||||
ungroup: vi.fn(),
|
ungroup: vi.fn(),
|
||||||
|
removeFromGroup: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStore(overrides = {}) {
|
function setupStore(overrides = {}) {
|
||||||
@@ -230,4 +231,46 @@ describe('GroupDetailPanel', () => {
|
|||||||
fireEvent.click(screen.getByText('Child Node Alpha'))
|
fireEvent.click(screen.getByText('Child Node Alpha'))
|
||||||
expect(setSelectedNode).toHaveBeenCalledWith('c1')
|
expect(setSelectedNode).toHaveBeenCalledWith('c1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes a child from the group via the remove button', () => {
|
||||||
|
const removeFromGroup = vi.fn()
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
|
const group = makeGroupNode()
|
||||||
|
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
|
||||||
|
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], removeFromGroup, snapshotHistory })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /remove router from group/i }))
|
||||||
|
expect(removeFromGroup).toHaveBeenCalledWith('g1', 'c1')
|
||||||
|
expect(snapshotHistory).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the existing group description from notes', () => {
|
||||||
|
const group = makeGroupNode()
|
||||||
|
group.data = { ...group.data, notes: 'Critical DMZ hosts' } as typeof group.data
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect((screen.getByLabelText('Description') as HTMLTextAreaElement).value).toBe('Critical DMZ hosts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits the description to the store on each change (so Ctrl+S captures it)', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
const group = makeGroupNode()
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'New notes' } })
|
||||||
|
expect(updateNode).toHaveBeenCalledWith('g1', { notes: 'New notes' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapshots history once at the start of an edit, not on every keystroke', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
|
const group = makeGroupNode()
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode, snapshotHistory })
|
||||||
|
renderPanel()
|
||||||
|
const textarea = screen.getByLabelText('Description')
|
||||||
|
fireEvent.change(textarea, { target: { value: 'a' } })
|
||||||
|
fireEvent.change(textarea, { target: { value: 'ab' } })
|
||||||
|
fireEvent.change(textarea, { target: { value: 'abc' } })
|
||||||
|
expect(snapshotHistory).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { Sidebar } from '../Sidebar'
|
import { Sidebar } from '../Sidebar'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
@@ -67,10 +67,12 @@ function mockAuth() {
|
|||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
onAddNode: vi.fn(),
|
onAddNode: vi.fn(),
|
||||||
onAddGroupRect: vi.fn(),
|
onAddGroupRect: vi.fn(),
|
||||||
|
onAddText: vi.fn(),
|
||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
onZigbeeImport: vi.fn(),
|
onZigbeeImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onOpenSettings: vi.fn(),
|
onOpenSettings: vi.fn(),
|
||||||
|
onOpenHistory: vi.fn(),
|
||||||
onOpenPending: vi.fn(),
|
onOpenPending: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,19 +245,10 @@ describe('Sidebar', () => {
|
|||||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
it('calls onOpenHistory when Scan History nav item is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan History'))
|
fireEvent.click(screen.getByText('Scan History'))
|
||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
expect(defaultProps.onOpenHistory).toHaveBeenCalledOnce()
|
||||||
})
|
|
||||||
|
|
||||||
// Regression: forceView must not freeze local state across rerenders.
|
|
||||||
it('allows switching views after forceView is set by parent', async () => {
|
|
||||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
|
||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
|
||||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
|
||||||
fireEvent.click(screen.getByText('Canvas'))
|
|
||||||
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onOpenSettings when Settings is clicked', () => {
|
it('calls onOpenSettings when Settings is clicked', () => {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"min-h-16 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -9,6 +9,7 @@ vi.mock('@/stores/authStore')
|
|||||||
|
|
||||||
const mockUpdateNode = vi.fn()
|
const mockUpdateNode = vi.fn()
|
||||||
const mockNotifyScanDeviceFound = vi.fn()
|
const mockNotifyScanDeviceFound = vi.fn()
|
||||||
|
const mockSetServiceStatuses = vi.fn()
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
static instances: MockWebSocket[] = []
|
static instances: MockWebSocket[] = []
|
||||||
@@ -33,6 +34,7 @@ describe('useStatusPolling', () => {
|
|||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
updateNode: mockUpdateNode,
|
updateNode: mockUpdateNode,
|
||||||
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
||||||
|
setServiceStatuses: mockSetServiceStatuses,
|
||||||
} as ReturnType<typeof useCanvasStore>)
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
|
|
||||||
vi.mocked(useAuthStore).mockReturnValue({
|
vi.mocked(useAuthStore).mockReturnValue({
|
||||||
@@ -50,6 +52,7 @@ describe('useStatusPolling', () => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
mockUpdateNode.mockClear()
|
mockUpdateNode.mockClear()
|
||||||
mockNotifyScanDeviceFound.mockClear()
|
mockNotifyScanDeviceFound.mockClear()
|
||||||
|
mockSetServiceStatuses.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not open WebSocket when not authenticated', () => {
|
it('does not open WebSocket when not authenticated', () => {
|
||||||
@@ -147,6 +150,17 @@ describe('useStatusPolling', () => {
|
|||||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('routes service_status messages to setServiceStatuses', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
|
||||||
|
ws.onmessage?.({
|
||||||
|
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
||||||
|
})
|
||||||
|
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
||||||
|
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores malformed JSON without throwing', () => {
|
it('ignores malformed JSON without throwing', () => {
|
||||||
renderHook(() => useStatusPolling())
|
renderHook(() => useStatusPolling())
|
||||||
const ws = MockWebSocket.instances[0]
|
const ws = MockWebSocket.instances[0]
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import type { ServiceStatus } from '@/types'
|
||||||
|
|
||||||
|
interface ServiceStatusEntry {
|
||||||
|
port?: number
|
||||||
|
protocol?: string
|
||||||
|
status: ServiceStatus
|
||||||
|
}
|
||||||
|
|
||||||
interface StatusMessage {
|
interface StatusMessage {
|
||||||
type?: string
|
type?: string
|
||||||
@@ -10,13 +17,14 @@ interface StatusMessage {
|
|||||||
response_time_ms?: number | null
|
response_time_ms?: number | null
|
||||||
run_id?: string
|
run_id?: string
|
||||||
devices_found?: number
|
devices_found?: number
|
||||||
|
services?: ServiceStatusEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
export function useStatusPolling() {
|
export function useStatusPolling() {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const { updateNode, notifyScanDeviceFound } = useCanvasStore()
|
const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||||
const { isAuthenticated, token } = useAuthStore()
|
const { isAuthenticated, token } = useAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,6 +47,8 @@ export function useStatusPolling() {
|
|||||||
const msg: StatusMessage = JSON.parse(event.data)
|
const msg: StatusMessage = JSON.parse(event.data)
|
||||||
if (msg.type === 'scan_device_found') {
|
if (msg.type === 'scan_device_found') {
|
||||||
notifyScanDeviceFound()
|
notifyScanDeviceFound()
|
||||||
|
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
||||||
|
setServiceStatuses(msg.node_id, msg.services)
|
||||||
} else if (msg.node_id && msg.status) {
|
} else if (msg.node_id && msg.status) {
|
||||||
updateNode(msg.node_id, {
|
updateNode(msg.node_id, {
|
||||||
status: msg.status,
|
status: msg.status,
|
||||||
@@ -59,5 +69,5 @@ export function useStatusPolling() {
|
|||||||
ws.close()
|
ws.close()
|
||||||
wsRef.current = null
|
wsRef.current = null
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound])
|
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,36 @@ describe('canvasStore', () => {
|
|||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: { nodes: [], edges: [] },
|
clipboard: { nodes: [], edges: [] },
|
||||||
|
serviceStatuses: {},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setServiceStatuses stores live status keyed by node/port/protocol', () => {
|
||||||
|
const { setServiceStatuses } = useCanvasStore.getState()
|
||||||
|
setServiceStatuses('node-1', [
|
||||||
|
{ port: 80, protocol: 'tcp', status: 'offline' },
|
||||||
|
{ port: 443, protocol: 'tcp', status: 'online' },
|
||||||
|
])
|
||||||
|
const { serviceStatuses } = useCanvasStore.getState()
|
||||||
|
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
|
||||||
|
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setServiceStatuses merges without dropping other nodes', () => {
|
||||||
|
const { setServiceStatuses } = useCanvasStore.getState()
|
||||||
|
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
|
||||||
|
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
|
||||||
|
const { serviceStatuses } = useCanvasStore.getState()
|
||||||
|
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
|
||||||
|
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mark canvas unsaved on a service status update', () => {
|
||||||
|
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||||
|
useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }])
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('setEditingTextId sets and clears editing text id', () => {
|
it('setEditingTextId sets and clears editing text id', () => {
|
||||||
const { setEditingTextId } = useCanvasStore.getState()
|
const { setEditingTextId } = useCanvasStore.getState()
|
||||||
setEditingTextId('t1')
|
setEditingTextId('t1')
|
||||||
@@ -487,6 +514,186 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── addToGroup ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('addToGroup nests a top-level node with parent-relative position', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(moved?.parentId).toBe('g1')
|
||||||
|
expect(moved?.extent).toBe('parent')
|
||||||
|
expect(moved?.data.parent_id).toBe('g1')
|
||||||
|
// 300-76=224, 200-52=148
|
||||||
|
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup places the group before the child in the array', () => {
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
// child first to prove reordering
|
||||||
|
useCanvasStore.setState({ nodes: [child, group] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup is a no-op when target is not a group', () => {
|
||||||
|
const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [notGroup, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('s1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup is a no-op when child already belongs to the group', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup snapshots history and marks unsaved', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── addToContainer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('addToContainer nests a top-level node under a container_mode node', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(moved?.parentId).toBe('px1')
|
||||||
|
expect(moved?.extent).toBe('parent')
|
||||||
|
expect(moved?.data.parent_id).toBe('px1')
|
||||||
|
// 300-76=224, 200-52=148
|
||||||
|
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer works for any container_mode type (docker_host)', () => {
|
||||||
|
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [host, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('dh1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer places the container before the child in the array', () => {
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
useCanvasStore.setState({ nodes: [child, container] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer is a no-op when target is not in container_mode', () => {
|
||||||
|
const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [notContainer, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer is a no-op when child already belongs to the container', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer snapshots history and marks unsaved', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── removeFromGroup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const released = nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(released?.parentId).toBeUndefined()
|
||||||
|
expect(released?.extent).toBeUndefined()
|
||||||
|
expect(released?.data.parent_id).toBeUndefined()
|
||||||
|
// 224+76=300, 148+52=200
|
||||||
|
expect(released?.position).toEqual({ x: 300, y: 200 })
|
||||||
|
// group survives
|
||||||
|
expect(nodes.find((n) => n.id === 'g1')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeFromGroup is a no-op when child is not in the group', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeFromGroup snapshots history and marks unsaved', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('updateEdge updates edge data and marks unsaved', () => {
|
it('updateEdge updates edge data and marks unsaved', () => {
|
||||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||||
useCanvasStore.getState().markSaved()
|
useCanvasStore.getState().markSaved()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
addEdge,
|
addEdge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
@@ -21,6 +21,10 @@ type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
|||||||
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
||||||
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
||||||
|
|
||||||
|
/** Key for the live per-service status overlay. */
|
||||||
|
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
|
||||||
|
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
edges: Edge<EdgeData>[]
|
edges: Edge<EdgeData>[]
|
||||||
@@ -28,6 +32,8 @@ interface CanvasState {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
selectedNodeIds: string[]
|
selectedNodeIds: string[]
|
||||||
scanEventTs: number
|
scanEventTs: number
|
||||||
|
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
|
||||||
|
serviceStatuses: Record<string, ServiceStatus>
|
||||||
|
|
||||||
// History
|
// History
|
||||||
past: HistoryEntry[]
|
past: HistoryEntry[]
|
||||||
@@ -62,12 +68,16 @@ interface CanvasState {
|
|||||||
toggleNodeCollapsed: (id: string) => void
|
toggleNodeCollapsed: (id: string) => void
|
||||||
createGroup: (nodeIds: string[], name: string) => void
|
createGroup: (nodeIds: string[], name: string) => void
|
||||||
ungroup: (groupId: string) => void
|
ungroup: (groupId: string) => void
|
||||||
|
addToGroup: (groupId: string, childId: string) => void
|
||||||
|
addToContainer: (containerId: string, childId: string) => void
|
||||||
|
removeFromGroup: (groupId: string, childId: string) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
markUnsaved: () => void
|
markUnsaved: () => void
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||||
fitViewPending: boolean
|
fitViewPending: boolean
|
||||||
clearFitViewPending: () => void
|
clearFitViewPending: () => void
|
||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
|
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
|
||||||
hideIp: boolean
|
hideIp: boolean
|
||||||
toggleHideIp: () => void
|
toggleHideIp: () => void
|
||||||
setHideIp: (value: boolean) => void
|
setHideIp: (value: boolean) => void
|
||||||
@@ -86,6 +96,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
hideIp: readHideIp(),
|
hideIp: readHideIp(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
|
serviceStatuses: {},
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
|
|
||||||
past: [],
|
past: [],
|
||||||
@@ -575,12 +586,137 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Nest an existing top-level node inside a group. Inverse of removeFromGroup.
|
||||||
|
addToGroup: (groupId, childId) =>
|
||||||
|
set((state) => {
|
||||||
|
const group = state.nodes.find((n) => n.id === groupId)
|
||||||
|
const child = state.nodes.find((n) => n.id === childId)
|
||||||
|
if (!group || !child || group.data.type !== 'group') return state
|
||||||
|
if (child.id === groupId || child.parentId === groupId) return state
|
||||||
|
|
||||||
|
const updatedNodes = state.nodes.map((n) => {
|
||||||
|
if (n.id !== childId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: groupId,
|
||||||
|
extent: 'parent' as const,
|
||||||
|
// Absolute → group-relative. Clamp so the node stays inside the box.
|
||||||
|
position: {
|
||||||
|
x: Math.max(8, n.position.x - group.position.x),
|
||||||
|
y: Math.max(8, n.position.y - group.position.y),
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
data: { ...n.data, parent_id: groupId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// React Flow requires the parent to precede its children in the array.
|
||||||
|
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||||
|
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||||
|
const groupIdx = others.findIndex((n) => n.id === groupId)
|
||||||
|
const nodes = [
|
||||||
|
...others.slice(0, groupIdx + 1),
|
||||||
|
movedChild,
|
||||||
|
...others.slice(groupIdx + 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Nest an existing top-level node inside a container node (proxmox /
|
||||||
|
// docker_host / … in container_mode). Mirrors addToGroup but the target is
|
||||||
|
// any node with data.container_mode === true rather than a group.
|
||||||
|
addToContainer: (containerId, childId) =>
|
||||||
|
set((state) => {
|
||||||
|
const container = state.nodes.find((n) => n.id === containerId)
|
||||||
|
const child = state.nodes.find((n) => n.id === childId)
|
||||||
|
if (!container || !child || container.data.container_mode !== true) return state
|
||||||
|
if (child.id === containerId || child.parentId === containerId) return state
|
||||||
|
|
||||||
|
const updatedNodes = state.nodes.map((n) => {
|
||||||
|
if (n.id !== childId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: containerId,
|
||||||
|
extent: 'parent' as const,
|
||||||
|
// Absolute → container-relative. Clamp so the node stays inside.
|
||||||
|
position: {
|
||||||
|
x: Math.max(8, n.position.x - container.position.x),
|
||||||
|
y: Math.max(8, n.position.y - container.position.y),
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
data: { ...n.data, parent_id: containerId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// React Flow requires the parent to precede its children in the array.
|
||||||
|
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||||
|
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||||
|
const containerIdx = others.findIndex((n) => n.id === containerId)
|
||||||
|
const nodes = [
|
||||||
|
...others.slice(0, containerIdx + 1),
|
||||||
|
movedChild,
|
||||||
|
...others.slice(containerIdx + 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Release a single child from a group back to the canvas. Group stays.
|
||||||
|
removeFromGroup: (groupId, childId) =>
|
||||||
|
set((state) => {
|
||||||
|
const group = state.nodes.find((n) => n.id === groupId)
|
||||||
|
const child = state.nodes.find((n) => n.id === childId)
|
||||||
|
if (!group || !child || child.parentId !== groupId) return state
|
||||||
|
|
||||||
|
const nodes = state.nodes.map((n) => {
|
||||||
|
if (n.id !== childId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: undefined,
|
||||||
|
extent: undefined,
|
||||||
|
position: {
|
||||||
|
x: n.position.x + group.position.x,
|
||||||
|
y: n.position.y + group.position.y,
|
||||||
|
},
|
||||||
|
data: { ...n.data, parent_id: undefined },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||||
|
|
||||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||||
|
|
||||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||||
|
|
||||||
|
setServiceStatuses: (nodeId, statuses) =>
|
||||||
|
set((state) => {
|
||||||
|
// Live overlay only — never touches node data, so it stays out of saves.
|
||||||
|
const next = { ...state.serviceStatuses }
|
||||||
|
for (const s of statuses) {
|
||||||
|
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
|
||||||
|
}
|
||||||
|
return { serviceStatuses: next }
|
||||||
|
}),
|
||||||
|
|
||||||
toggleHideIp: () => set((s) => {
|
toggleHideIp: () => set((s) => {
|
||||||
const hideIp = !s.hideIp
|
const hideIp = !s.hideIp
|
||||||
writeHideIp(hideIp)
|
writeHideIp(hideIp)
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export interface ServiceInfo {
|
|||||||
category?: string
|
category?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServiceStatus = 'online' | 'offline' | 'unknown'
|
||||||
|
|
||||||
export interface NodeProperty {
|
export interface NodeProperty {
|
||||||
key: string
|
key: string
|
||||||
value: string
|
value: string
|
||||||
|
|||||||
@@ -53,6 +53,33 @@ describe('generateMarkdownTable', () => {
|
|||||||
expect(md).toContain('A\\|B')
|
expect(md).toContain('A\\|B')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('escapes backslashes before pipes so the escape char is not ambiguous', () => {
|
||||||
|
const nodes = [makeNode({ label: 'A\\|B' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
// backslash doubled, then the literal pipe escaped
|
||||||
|
expect(md).toContain('A\\\\\\|B')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses newlines in cell values so they do not break the table', () => {
|
||||||
|
const nodes = [makeNode({ label: 'line1\nline2', hostname: 'a\r\nb' })]
|
||||||
|
const lines = generateMarkdownTable(nodes).split('\n')
|
||||||
|
// header + separator + exactly one data row (no extra line from the value)
|
||||||
|
expect(lines).toHaveLength(3)
|
||||||
|
expect(lines[2]).toContain('line1 line2')
|
||||||
|
expect(lines[2]).toContain('a b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes pipe characters inside service names', () => {
|
||||||
|
const nodes = [makeNode({
|
||||||
|
label: 'Server',
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'web|proxy' }],
|
||||||
|
})]
|
||||||
|
const lines = generateMarkdownTable(nodes).split('\n')
|
||||||
|
// header + separator + exactly one data row — the pipe must not add a column
|
||||||
|
expect(lines).toHaveLength(3)
|
||||||
|
expect(lines[2]).toContain('web\\|proxy')
|
||||||
|
})
|
||||||
|
|
||||||
it('generates one row per non-groupRect node', () => {
|
it('generates one row per non-groupRect node', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
makeNode({ type: 'server', label: 'A' }, '1'),
|
makeNode({ type: 'server', label: 'A' }, '1'),
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ const EMPTY = '—'
|
|||||||
|
|
||||||
function cell(v: string | null | undefined): string {
|
function cell(v: string | null | undefined): string {
|
||||||
if (!v) return EMPTY
|
if (!v) return EMPTY
|
||||||
// Escape pipe chars so they don't break the table
|
// Escape backslashes first, then pipes, and collapse newlines so they don't break the table
|
||||||
return v.replace(/\|/g, '\\|')
|
return v
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\|/g, '\\|')
|
||||||
|
.replace(/\r?\n/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||||
@@ -27,7 +30,7 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
|||||||
cell(d.ip),
|
cell(d.ip),
|
||||||
cell(d.hostname),
|
cell(d.hostname),
|
||||||
cell(d.status),
|
cell(d.status),
|
||||||
services,
|
cell(services),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user