Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c5c538b4 | |||
| a816faa0b9 | |||
| fbfacec6dc | |||
| b5eb8d1b74 | |||
| 0193f933ce | |||
| 5ad5eba58c | |||
| ef96cafcc8 | |||
| 6c9974b357 | |||
| ce5fc785e1 | |||
| 0019c086cf | |||
| 0eff7da46e | |||
| 2e6ee9dad2 | |||
| 81b109f981 | |||
| 73b16a7620 | |||
| a37bf101d2 | |||
| 5def6b7fbf | |||
| eb235cb101 | |||
| 04a1c63558 | |||
| 88f0c03c57 | |||
| 718aff5918 | |||
| 70311e6331 | |||
| 6a3da5aded | |||
| 35c3d00f17 | |||
| 3a5cb0de21 | |||
| f72d44d5e5 | |||
| a7b244502e | |||
| 72d5a51b44 | |||
| 12f46715c1 | |||
| 62f674b15d | |||
| b0a67744f5 | |||
| 04069e080a | |||
| dd1f690892 | |||
| 8b04deb608 | |||
| 531fb12eab | |||
| e666abefad | |||
| f1e9fd7cf8 | |||
| 9134812e32 | |||
| 4844576c3b | |||
| 4976f2e694 | |||
| d6a7b062f4 | |||
| 94c6ac7fa7 | |||
| e0f96001e2 | |||
| 3d89ba3b6f | |||
| 3afc8ed3d8 | |||
| 9e8bab5dec | |||
| 9d9fdd61e9 | |||
| b0df8f389a |
@@ -45,6 +45,7 @@ htmlcov/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db.back
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
@@ -2,13 +2,14 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
||||
# Install nmap for network scanning + iputils-ping for ping-based status checks + curl for the health check
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
COPY VERSION /app/VERSION
|
||||
|
||||
# Create data directory (volume mount point)
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ .
|
||||
COPY VERSION ../VERSION
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve
|
||||
|
||||
@@ -17,6 +17,10 @@ from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.scanner import request_cancel, run_scan
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
device_ids: list[str]
|
||||
|
||||
|
||||
class ScanConfig(BaseModel):
|
||||
ranges: list[str]
|
||||
|
||||
@@ -99,6 +103,61 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/pending/bulk-approve", response_model=dict)
|
||||
async def bulk_approve_devices(
|
||||
payload: BulkActionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
node_ids: list[str] = []
|
||||
for device in devices:
|
||||
device.status = "approved"
|
||||
node = Node(
|
||||
label=device.hostname or device.ip,
|
||||
type=device.suggested_type or "generic",
|
||||
ip=device.ip,
|
||||
hostname=device.hostname,
|
||||
status="unknown",
|
||||
services=device.services or [],
|
||||
)
|
||||
db.add(node)
|
||||
node_ids.append(node.id)
|
||||
await db.commit()
|
||||
approved_device_ids = [d.id for d in devices]
|
||||
return {
|
||||
"approved": len(node_ids),
|
||||
"node_ids": node_ids,
|
||||
"device_ids": approved_device_ids,
|
||||
"skipped": len(payload.device_ids) - len(node_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pending/bulk-hide", response_model=dict)
|
||||
async def bulk_hide_devices(
|
||||
payload: BulkActionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
for device in devices:
|
||||
device.status = "hidden"
|
||||
await db.commit()
|
||||
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||
async def approve_device(
|
||||
device_id: str,
|
||||
|
||||
@@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _read_version() -> str:
|
||||
for candidate in [
|
||||
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
||||
Path("/app/VERSION"), # Docker image
|
||||
]:
|
||||
if candidate.exists():
|
||||
return candidate.read_text().strip()
|
||||
return "unknown"
|
||||
|
||||
APP_VERSION = _read_version()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -6,7 +8,9 @@ from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.config import APP_VERSION, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ensure the data directory exists before SQLite tries to open the file
|
||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -23,7 +27,22 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _backup_db() -> None:
|
||||
db_path = Path(settings.sqlite_path)
|
||||
if not db_path.exists():
|
||||
return
|
||||
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
|
||||
if backup_path.exists():
|
||||
return
|
||||
try:
|
||||
shutil.copy2(db_path, backup_path)
|
||||
logger.info("DB backup created: %s", backup_path.name)
|
||||
except OSError:
|
||||
logger.warning("Could not create DB backup at %s", backup_path)
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
_backup_db()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns introduced after initial schema (idempotent)
|
||||
@@ -61,6 +80,33 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||
with suppress(OperationalError):
|
||||
rows = await conn.exec_driver_sql(
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for row in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||
if cpu_count is not None:
|
||||
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||
if ram_gb is not None:
|
||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||
if disk_gb is not None:
|
||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||
import json as _json
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||
(_json.dumps(props), node_id),
|
||||
)
|
||||
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
||||
|
||||
@@ -42,6 +42,7 @@ class Node(Base):
|
||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||
@@ -49,7 +50,6 @@ class Node(Base):
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
||||
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
||||
|
||||
@@ -69,6 +69,7 @@ class Edge(Base):
|
||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||
source_handle: Mapped[str | None] = mapped_column(String)
|
||||
target_handle: Mapped[str | None] = mapped_column(String)
|
||||
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app = FastAPI(
|
||||
title="Homelable API",
|
||||
version="1.8.3",
|
||||
version="1.9.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class NodeSave(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
properties: list[Any] = []
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
bottom_handles: int = 1
|
||||
@@ -49,6 +50,7 @@ class EdgeSave(BaseModel):
|
||||
animated: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
|
||||
@@ -17,6 +17,7 @@ class EdgeBase(BaseModel):
|
||||
animated: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
@@ -38,6 +39,7 @@ class EdgeUpdate(BaseModel):
|
||||
animated: str | None = None
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
|
||||
@@ -27,6 +27,7 @@ class NodeBase(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
properties: list[dict[str, Any]] = []
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
bottom_handles: int = 1
|
||||
@@ -59,6 +60,7 @@ class NodeUpdate(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool | None = None
|
||||
properties: list[dict[str, Any]] | None = None
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
bottom_handles: int | None = None
|
||||
|
||||
@@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str:
|
||||
return 'snake'
|
||||
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
||||
return 'none'
|
||||
if v in ('snake', 'flow'):
|
||||
if v in ('snake', 'flow', 'basic'):
|
||||
return str(v)
|
||||
return 'none'
|
||||
|
||||
@@ -19,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
if check_method == "none":
|
||||
return {"status": "online", "response_time_ms": None}
|
||||
|
||||
host = target or ip
|
||||
# Use only the first IP when the field contains comma-separated addresses
|
||||
raw_ip = ip.split(",")[0].strip() if ip else None
|
||||
host = target or raw_ip
|
||||
if not host:
|
||||
return {"status": "unknown", "response_time_ms": None}
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
scan_config.json
|
||||
homelab.db.*
|
||||
|
||||
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = ["*/migrations/*", "*/tests/*"]
|
||||
concurrency = ["thread"]
|
||||
core = "sysmon"
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
||||
@@ -9,7 +9,7 @@ pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.22
|
||||
python-multipart==0.0.26
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
pyyaml==6.0.2
|
||||
@@ -21,6 +21,6 @@ zeroconf==0.131.0
|
||||
# Dev
|
||||
ruff==0.6.9
|
||||
mypy==1.11.2
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
pytest==9.0.3
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==5.0.0
|
||||
|
||||
@@ -256,3 +256,295 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["width"] is None
|
||||
assert canvas["nodes"][0]["height"] is None
|
||||
|
||||
|
||||
# ── properties ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["properties"] == []
|
||||
|
||||
|
||||
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
|
||||
props = [
|
||||
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
|
||||
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
|
||||
]
|
||||
n1 = node_payload(properties=props)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
returned = canvas["nodes"][0]["properties"]
|
||||
assert len(returned) == 2
|
||||
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
|
||||
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
|
||||
|
||||
|
||||
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
n1_updated = {**n1, "properties": [
|
||||
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
|
||||
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
|
||||
]}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
props = canvas["nodes"][0]["properties"]
|
||||
assert len(props) == 2
|
||||
assert props[0]["value"] == "64 GB"
|
||||
assert props[1]["key"] == "Disk"
|
||||
|
||||
|
||||
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
|
||||
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
|
||||
n1 = node_payload(properties=props)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["properties"][0]["icon"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
n1_cleared = {**n1, "properties": []}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["properties"] == []
|
||||
|
||||
|
||||
# ── edge waypoints & handles ──────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["waypoints"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
returned = canvas["edges"][0]["waypoints"]
|
||||
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||
|
||||
|
||||
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
|
||||
|
||||
|
||||
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(bottom_handles=3)
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["source_handle"] == "bottom-1"
|
||||
assert edge["target_handle"] == "top"
|
||||
|
||||
|
||||
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["animated"] == "snake"
|
||||
|
||||
|
||||
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["animated"] == "basic"
|
||||
|
||||
|
||||
# ── node fields ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(
|
||||
type="server",
|
||||
label="Main Server",
|
||||
hostname="server.local",
|
||||
ip="192.168.1.10",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="Ubuntu 22.04",
|
||||
status="online",
|
||||
check_method="http",
|
||||
check_target="http://192.168.1.10",
|
||||
services=[{"name": "nginx", "port": 80}],
|
||||
notes="Primary web server",
|
||||
pos_x=150.0,
|
||||
pos_y=250.0,
|
||||
bottom_handles=2,
|
||||
)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["hostname"] == "server.local"
|
||||
assert node["ip"] == "192.168.1.10"
|
||||
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||
assert node["os"] == "Ubuntu 22.04"
|
||||
assert node["status"] == "online"
|
||||
assert node["check_method"] == "http"
|
||||
assert node["check_target"] == "http://192.168.1.10"
|
||||
assert node["services"] == [{"name": "nginx", "port": 80}]
|
||||
assert node["notes"] == "Primary web server"
|
||||
assert node["pos_x"] == 150.0
|
||||
assert node["pos_y"] == 250.0
|
||||
assert node["bottom_handles"] == 2
|
||||
|
||||
|
||||
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(bottom_handles=4)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["bottom_handles"] == 4
|
||||
|
||||
|
||||
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["bottom_handles"] == 1
|
||||
|
||||
|
||||
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
|
||||
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
|
||||
n1 = node_payload(services=services, notes="My NAS device")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["services"] == services
|
||||
assert node["notes"] == "My NAS device"
|
||||
|
||||
|
||||
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["check_method"] == "ping"
|
||||
assert node["check_target"] == "192.168.1.1"
|
||||
|
||||
|
||||
# ── parent/child nodes ────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
|
||||
parent = node_payload(type="proxmox", label="PVE Host")
|
||||
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||
assert node_map[child["id"]]["parent_id"] == parent["id"]
|
||||
assert node_map[parent["id"]]["parent_id"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
|
||||
parent = node_payload(type="proxmox", label="PVE Host")
|
||||
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
# Remove both parent and child
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"] == []
|
||||
|
||||
|
||||
# ── groupRect / group node ────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
|
||||
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
|
||||
member = node_payload(type="server", label="Member", parent_id=group["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||
assert node_map[group["id"]]["type"] == "group"
|
||||
assert node_map[group["id"]]["width"] == 400.0
|
||||
assert node_map[group["id"]]["height"] == 300.0
|
||||
assert node_map[member["id"]]["parent_id"] == group["id"]
|
||||
|
||||
|
||||
# ── viewport ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||
|
||||
|
||||
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
|
||||
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
|
||||
|
||||
|
||||
# ── edge types ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["type"] == "vlan"
|
||||
assert edge["vlan_id"] == 10
|
||||
assert edge["label"] == "VLAN 10"
|
||||
|
||||
|
||||
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
|
||||
"""Second save updates an existing edge (exercises the db_edge branch)."""
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], label="original")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["label"] == "updated"
|
||||
assert edge["custom_color"] == "#ff0000"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Tests for automatic DB backup before migrations.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.database import _backup_db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_db(tmp_path: Path):
|
||||
db = tmp_path / "homelab.db"
|
||||
db.write_bytes(b"SQLite placeholder")
|
||||
return db
|
||||
|
||||
|
||||
def test_backup_created_when_db_exists(tmp_db: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "1.9"):
|
||||
mock_settings.sqlite_path = str(tmp_db)
|
||||
_backup_db()
|
||||
backup = tmp_db.parent / "homelab.db.back-1.9"
|
||||
assert backup.exists()
|
||||
assert backup.read_bytes() == b"SQLite placeholder"
|
||||
|
||||
|
||||
def test_backup_skipped_when_db_missing(tmp_path: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "1.9"):
|
||||
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
|
||||
_backup_db()
|
||||
assert not any(tmp_path.glob("*.back-*"))
|
||||
|
||||
|
||||
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "1.9"):
|
||||
mock_settings.sqlite_path = str(tmp_db)
|
||||
_backup_db()
|
||||
backup = tmp_db.parent / "homelab.db.back-1.9"
|
||||
backup.write_bytes(b"original backup")
|
||||
_backup_db()
|
||||
assert backup.read_bytes() == b"original backup"
|
||||
|
||||
|
||||
def test_backup_version_in_filename(tmp_db: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "2.0"):
|
||||
mock_settings.sqlite_path = str(tmp_db)
|
||||
_backup_db()
|
||||
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
|
||||
@@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
||||
async def test_create_node_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# --- Properties tests ---
|
||||
|
||||
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
|
||||
"""New node has an empty properties list by default."""
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["properties"] == []
|
||||
|
||||
|
||||
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
|
||||
"""Node created with properties round-trips correctly."""
|
||||
props = [
|
||||
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
|
||||
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
|
||||
]
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_patch_node_properties(client: AsyncClient, headers: dict):
|
||||
"""PATCH with properties replaces the full properties array."""
|
||||
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
|
||||
"""PATCH that omits properties leaves existing properties untouched."""
|
||||
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
# PATCH only the label — properties must survive
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
assert res.json()["label"] == "Updated"
|
||||
|
||||
|
||||
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
|
||||
"""PATCH with properties=[] explicitly clears all properties."""
|
||||
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == []
|
||||
|
||||
|
||||
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
|
||||
"""GET /nodes/:id returns the properties field."""
|
||||
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
||||
"""A property with icon=null is valid and round-trips correctly."""
|
||||
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
assert create.json()["properties"] == props
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Tests for the hardware → properties migration logic.
|
||||
|
||||
We test the migration function directly against an in-memory SQLite database
|
||||
so we can set up legacy rows (with hardware columns, NULL properties) and
|
||||
verify the migration produces the expected properties JSON.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
async def _setup_legacy_table(conn):
|
||||
"""Create a minimal nodes table that mimics the pre-migration schema."""
|
||||
await conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'generic',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
cpu_model TEXT,
|
||||
cpu_count INTEGER,
|
||||
ram_gb REAL,
|
||||
disk_gb REAL,
|
||||
show_hardware BOOLEAN NOT NULL DEFAULT 0,
|
||||
properties JSON
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def _run_migration(conn):
|
||||
"""Run only the properties migration portion (extracted from init_db)."""
|
||||
rows = await conn.exec_driver_sql(
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for row in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||
if cpu_count is not None:
|
||||
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||
if ram_gb is not None:
|
||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||
if disk_gb is not None:
|
||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||
(json.dumps(props), node_id),
|
||||
)
|
||||
|
||||
|
||||
async def _get_properties(conn, node_id: str) -> list:
|
||||
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
||||
raw = rows.fetchone()[0]
|
||||
return json.loads(raw) if raw else []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_full_hardware():
|
||||
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-1")
|
||||
|
||||
assert len(props) == 4
|
||||
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
|
||||
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
|
||||
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
|
||||
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_partial_hardware():
|
||||
"""Node with only cpu_model and ram_gb → 2 property entries."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||
("node-2", "Ryzen 5 5600", 16.0, 0),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-2")
|
||||
|
||||
assert len(props) == 2
|
||||
assert props[0]["key"] == "CPU Model"
|
||||
assert props[0]["visible"] is False
|
||||
assert props[1]["key"] == "RAM"
|
||||
assert props[1]["icon"] == "MemoryStick"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_no_hardware():
|
||||
"""Node with no hardware fields → empty properties array."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id) VALUES (?)",
|
||||
("node-3",),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-3")
|
||||
|
||||
assert props == []
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent():
|
||||
"""Running migration twice does not duplicate properties."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
|
||||
("node-4", "Core i5", 1),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
await _run_migration(conn) # second pass — node already has properties, should be skipped
|
||||
props = await _get_properties(conn, "node-4")
|
||||
|
||||
assert len(props) == 1
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_show_hardware_false_sets_visible_false():
|
||||
"""show_hardware=0 means all migrated properties have visible=False."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||
("node-5", "ARM Cortex-A72", 4.0, 0),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-5")
|
||||
|
||||
assert all(p["visible"] is False for p in props)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_already_migrated_node_not_touched():
|
||||
"""Node that already has properties is skipped — existing properties preserved."""
|
||||
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
|
||||
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-6")
|
||||
|
||||
assert props == existing
|
||||
await engine.dispose()
|
||||
@@ -444,3 +444,101 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
|
||||
|
||||
# --- Bulk approve ---
|
||||
|
||||
@pytest.fixture
|
||||
async def two_pending_devices(db_session):
|
||||
devices = []
|
||||
for i in range(2):
|
||||
d = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=f"192.168.1.{10 + i}",
|
||||
mac=None,
|
||||
hostname=f"host-{i}",
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(d)
|
||||
devices.append(d)
|
||||
await db_session.commit()
|
||||
for d in devices:
|
||||
await db_session.refresh(d)
|
||||
return devices
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 2
|
||||
assert len(data["node_ids"]) == 2
|
||||
assert len(data["device_ids"]) == 2
|
||||
assert data["skipped"] == 0
|
||||
# Pending list should now be empty
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
# Approve first device individually first
|
||||
await client.post(
|
||||
f"/api/v1/scan/pending/{ids[0]}/approve",
|
||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
# Bulk approve both — first one is already approved (not pending), should be skipped
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# --- Bulk hide ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 2
|
||||
assert data["skipped"] == 0
|
||||
# Should appear in hidden list
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert len(hidden_res.json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
# Hide first device individually first
|
||||
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
|
||||
# Bulk hide both — first is already hidden (not pending anymore)
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="128" height="128" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="16" height="16" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
|
||||
<!-- House body -->
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Floor line (subtle) -->
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
|
||||
<!-- Door -->
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
|
||||
<!-- Network lines (drawn under nodes) -->
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
|
||||
<!-- Center hub glow -->
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<!-- Center hub -->
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Left node -->
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Right node -->
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Top node -->
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="640" height="164" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(4, 4)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="360" height="92" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(4, 4)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="200" height="51" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(4, 4)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Icon (72×72, scaled from 64 viewBox) -->
|
||||
<g transform="translate(4, 4) scale(1)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
|
||||
<!-- Text -->
|
||||
<text x="80" y="42"
|
||||
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||
font-weight="600"
|
||||
font-size="30"
|
||||
letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="81" y="58"
|
||||
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||
font-weight="400"
|
||||
font-size="11"
|
||||
fill="#8b949e"
|
||||
letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.8.3",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -343,6 +343,10 @@ export default function App() {
|
||||
setEditEdgeId(edge.id)
|
||||
}, [])
|
||||
|
||||
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
||||
handleEditNode(node.id)
|
||||
}, [handleEditNode])
|
||||
|
||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||
if (!editEdgeId) return
|
||||
snapshotHistory()
|
||||
@@ -357,6 +361,13 @@ export default function App() {
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||
|
||||
const handleClearWaypoints = useCallback(() => {
|
||||
if (!editEdgeId) return
|
||||
snapshotHistory()
|
||||
updateEdge(editEdgeId, { waypoints: [] })
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||
|
||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||
|
||||
@@ -393,6 +404,7 @@ export default function App() {
|
||||
<CanvasContainer
|
||||
onConnect={handleEdgeConnect}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onOpenPending={(deviceId) => {
|
||||
setHighlightPendingId(undefined)
|
||||
@@ -446,6 +458,7 @@ export default function App() {
|
||||
onClose={() => setEditEdgeId(null)}
|
||||
onSubmit={handleEdgeUpdate}
|
||||
onDelete={handleEdgeDelete}
|
||||
onClearWaypoints={handleClearWaypoints}
|
||||
initial={editEdge?.data}
|
||||
title="Edit Link"
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,8 @@ export const scanApi = {
|
||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||
bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
|
||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
@@ -143,6 +144,7 @@ const XYFLOW_MOCK = {
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ import type { NodeData, EdgeData } from '@/types'
|
||||
interface CanvasContainerProps {
|
||||
onConnect?: (connection: Connection) => void
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||
onNodeDragStart?: () => void
|
||||
onOpenPending?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
@@ -68,6 +69,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onEdgeDoubleClick?.(edge)
|
||||
}, [onEdgeDoubleClick])
|
||||
|
||||
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||
onNodeDoubleClick?.(node)
|
||||
}, [onNodeDoubleClick])
|
||||
|
||||
const handleBeforeDelete = useCallback(async () => {
|
||||
snapshotHistory()
|
||||
return true
|
||||
}, [snapshotHistory])
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||
<ReactFlow
|
||||
@@ -79,26 +94,29 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
||||
onBeforeDelete={handleBeforeDelete}
|
||||
selectionOnDrag={lassoMode}
|
||||
panOnDrag={lassoMode ? [1, 2] : true}
|
||||
panActivationKeyCode="Space"
|
||||
selectionMode={SelectionMode.Partial}
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
minZoom={0.25}
|
||||
maxZoom={2.5}
|
||||
snapToGrid
|
||||
snapGrid={[8, 8]}
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
elevateNodesOnSelect={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
isValidConnection={(connection) => connection.source !== connection.target}
|
||||
isValidConnection={isValidConnection}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
gap={16}
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Server } from 'lucide-react'
|
||||
import { BaseNode } from '../nodes/BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
let mockZoom = 1
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
Handle: () => null,
|
||||
Position: { Top: 'top', Bottom: 'bottom' },
|
||||
NodeResizer: () => null,
|
||||
useUpdateNodeInternals: () => vi.fn(),
|
||||
useViewport: () => ({ zoom: mockZoom }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/themeStore', () => ({
|
||||
useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/canvasStore', () => ({
|
||||
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/themes', () => ({
|
||||
THEMES: {
|
||||
dark: {
|
||||
colors: {
|
||||
statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' },
|
||||
nodeSubtextColor: '#8b949e',
|
||||
nodeLabelColor: '#e6edf3',
|
||||
nodeIconBackground: '#21262d',
|
||||
handleBackground: '#30363d',
|
||||
handleBorder: '#30363d',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeColors', () => ({
|
||||
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeIcons', () => ({
|
||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/maskIp', () => ({
|
||||
maskIp: (ip: string) => ip,
|
||||
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/propertyIcons', () => ({
|
||||
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/handleUtils', () => ({
|
||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||
}))
|
||||
|
||||
beforeEach(() => { mockZoom = 1 })
|
||||
|
||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: data.type ?? 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Node',
|
||||
type: 'server',
|
||||
status: 'online',
|
||||
services: [],
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderBaseNode(data: Partial<NodeData>) {
|
||||
const node = makeNode(data)
|
||||
return render(
|
||||
<BaseNode
|
||||
id={node.id}
|
||||
data={node.data}
|
||||
selected={false}
|
||||
icon={Server}
|
||||
type="server"
|
||||
dragging={false}
|
||||
zIndex={0}
|
||||
isConnectable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BaseNode — borderWidth zoom scaling', () => {
|
||||
beforeEach(() => { mockZoom = 1 })
|
||||
|
||||
it('borderWidth is 1px at zoom=1', () => {
|
||||
mockZoom = 1
|
||||
const { container } = renderBaseNode({})
|
||||
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||
})
|
||||
|
||||
it('borderWidth scales to 2px at zoom=0.5', () => {
|
||||
mockZoom = 0.5
|
||||
const { container } = renderBaseNode({})
|
||||
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px')
|
||||
})
|
||||
|
||||
it('borderWidth is clamped to 1px at zoom=2', () => {
|
||||
mockZoom = 2
|
||||
const { container } = renderBaseNode({})
|
||||
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||
})
|
||||
|
||||
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
||||
mockZoom = 0.5
|
||||
const node = makeNode({ status: 'online' })
|
||||
const { container } = render(
|
||||
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
|
||||
type="server" dragging={false} zIndex={0} isConnectable={true}
|
||||
positionAbsoluteX={0} positionAbsoluteY={0} />
|
||||
)
|
||||
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — properties rendering', () => {
|
||||
it('renders visible properties on the node', () => {
|
||||
renderBaseNode({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
|
||||
],
|
||||
})
|
||||
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render properties with visible=false', () => {
|
||||
renderBaseNode({
|
||||
properties: [
|
||||
{ key: 'Secret', value: 'hidden', icon: null, visible: false },
|
||||
],
|
||||
})
|
||||
expect(screen.queryByText('Secret')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when properties array is empty', () => {
|
||||
const { container } = renderBaseNode({ properties: [] })
|
||||
// No properties section — only the main node card
|
||||
expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders label and ip regardless of properties', () => {
|
||||
renderBaseNode({
|
||||
label: 'My Server',
|
||||
ip: '192.168.1.10',
|
||||
properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }],
|
||||
})
|
||||
expect(screen.getByText('My Server')).toBeDefined()
|
||||
expect(screen.getByText('192.168.1.10')).toBeDefined()
|
||||
expect(screen.getByText('OS')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — legacy hardware fallback', () => {
|
||||
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
||||
renderBaseNode({
|
||||
properties: undefined,
|
||||
show_hardware: true,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
ram_gb: 32,
|
||||
})
|
||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render legacy hardware when properties array is present (even if empty)', () => {
|
||||
renderBaseNode({
|
||||
properties: [],
|
||||
show_hardware: true,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
})
|
||||
// properties array exists → new system, legacy section skipped
|
||||
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render legacy hardware when show_hardware is false', () => {
|
||||
renderBaseNode({
|
||||
properties: undefined,
|
||||
show_hardware: false,
|
||||
cpu_model: 'Intel Xeon E5-2680',
|
||||
})
|
||||
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
SelectionMode: { Partial: 'partial' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
|
||||
@@ -104,6 +105,24 @@ describe('CanvasContainer', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// ── Node double-click ─────────────────────────────────────────────────────
|
||||
|
||||
it('calls onNodeDoubleClick prop when a node is double-clicked', () => {
|
||||
const onNodeDoubleClick = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
render(<CanvasContainer onNodeDoubleClick={onNodeDoubleClick} />)
|
||||
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||
expect(onNodeDoubleClick).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not throw when onNodeDoubleClick is not provided', () => {
|
||||
const node = makeNode('n1')
|
||||
render(<CanvasContainer />)
|
||||
expect(() => {
|
||||
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// ── Connection validation ─────────────────────────────────────────────────
|
||||
|
||||
it('isValidConnection returns false for self-connections', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { GroupNode } from '../nodes/GroupNode'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import type { Node } from '@xyflow/react'
|
||||
@@ -102,6 +102,19 @@ describe('GroupNode', () => {
|
||||
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
|
||||
})
|
||||
|
||||
it('allows dragging from the header while keeping rename controls nodrag', () => {
|
||||
renderGroupNode({ selected: true })
|
||||
|
||||
expect(screen.getByText('My Group').closest('div')).not.toHaveClass('nodrag')
|
||||
|
||||
const renameButton = screen.getByTitle('Rename group')
|
||||
expect(renameButton).toHaveClass('nodrag')
|
||||
|
||||
fireEvent.click(renameButton)
|
||||
|
||||
expect(screen.getByDisplayValue('My Group')).toHaveClass('nodrag')
|
||||
})
|
||||
|
||||
it('shows online/offline status summary from children', () => {
|
||||
const storeNodes = [
|
||||
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { GroupRectNode } from '../nodes/GroupRectNode'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
Handle: ({ id, type }: { id: string; type: string }) => <div data-testid={`handle-${id}`} data-type={type} />,
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
NodeResizer: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/canvasStore', () => ({
|
||||
useCanvasStore: (sel: (s: { setEditingGroupRectId: () => void }) => unknown) =>
|
||||
sel({ setEditingGroupRectId: vi.fn() }),
|
||||
}))
|
||||
|
||||
function makeNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||
return {
|
||||
id: 'zone1',
|
||||
type: 'groupRect',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
|
||||
}
|
||||
}
|
||||
|
||||
function renderZone(overrides: Partial<NodeData> = {}) {
|
||||
const node = makeNode(overrides)
|
||||
return render(
|
||||
<GroupRectNode
|
||||
id={node.id}
|
||||
data={node.data}
|
||||
selected={false}
|
||||
type="groupRect"
|
||||
dragging={false}
|
||||
zIndex={0}
|
||||
isConnectable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('GroupRectNode — handles', () => {
|
||||
it('renders source handles on all four sides', () => {
|
||||
renderZone()
|
||||
expect(screen.getByTestId('handle-zone-top')).toBeDefined()
|
||||
expect(screen.getByTestId('handle-zone-right')).toBeDefined()
|
||||
expect(screen.getByTestId('handle-zone-bottom')).toBeDefined()
|
||||
expect(screen.getByTestId('handle-zone-left')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders target handles on all four sides', () => {
|
||||
renderZone()
|
||||
expect(screen.getByTestId('handle-zone-top-t')).toBeDefined()
|
||||
expect(screen.getByTestId('handle-zone-right-t')).toBeDefined()
|
||||
expect(screen.getByTestId('handle-zone-bottom-t')).toBeDefined()
|
||||
expect(screen.getByTestId('handle-zone-left-t')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders 8 handles total (4 source + 4 target)', () => {
|
||||
renderZone()
|
||||
expect(screen.getAllByTestId(/^handle-zone-/).length).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupRectNode — label', () => {
|
||||
it('renders inside label by default', () => {
|
||||
renderZone({ label: 'DMZ' })
|
||||
expect(screen.getByText('DMZ')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders no label when label is empty', () => {
|
||||
renderZone({ label: '' })
|
||||
expect(screen.queryByText('DMZ')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
|
||||
|
||||
describe('buildWaypointPath — bezier (default)', () => {
|
||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||
// With only 2 pts (src + target), catmull-rom = cubic bezier
|
||||
const path = buildWaypointPath(0, 0, [], 100, 100)
|
||||
expect(path).toMatch(/^M 0 0 C/)
|
||||
})
|
||||
|
||||
it('routes through a single waypoint with smooth curve', () => {
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100)
|
||||
expect(path).toMatch(/^M 0 0 C/)
|
||||
// Should not be a straight polyline
|
||||
expect(path).not.toContain(' L ')
|
||||
})
|
||||
|
||||
it('routes through multiple waypoints', () => {
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100)
|
||||
expect(path).toMatch(/^M 0 0 C/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWaypointPath — smooth style', () => {
|
||||
it('builds a direct straight line with no waypoints (no bend)', () => {
|
||||
// Only 2 points → no intermediate vertex → no rounding needed
|
||||
expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100')
|
||||
})
|
||||
|
||||
it('routes through a single waypoint with straight lines (no intermediate bend)', () => {
|
||||
// 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth')
|
||||
// Should start at source and end at target
|
||||
expect(path).toMatch(/^M 0 0/)
|
||||
expect(path).toMatch(/100 100$/)
|
||||
// Should contain a quadratic bezier at the waypoint corner
|
||||
expect(path).toContain('Q')
|
||||
})
|
||||
|
||||
it('routes through multiple waypoints with rounded corners', () => {
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
|
||||
expect(path).toMatch(/^M 0 0/)
|
||||
expect(path).toMatch(/100 100$/)
|
||||
expect(path).toContain('Q')
|
||||
})
|
||||
|
||||
it('does not round corners when segment is too short (r clamped to 0)', () => {
|
||||
// Adjacent waypoints very close together — r → 0, falls back to L
|
||||
const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth')
|
||||
expect(path).toMatch(/^M 0 0/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snap45', () => {
|
||||
// Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px)
|
||||
it('snaps horizontal direction when close (deviation < threshold)', () => {
|
||||
// (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps
|
||||
const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 })
|
||||
expect(r.y).toBe(0)
|
||||
expect(r.x).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('snaps vertical direction when close', () => {
|
||||
const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 })
|
||||
expect(r.x).toBe(0)
|
||||
expect(r.y).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('snaps 45° diagonal when close', () => {
|
||||
// (80, 83) — nearly 45°, deviation ≈ 2px → snaps
|
||||
const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 })
|
||||
expect(r.x).toBe(r.y)
|
||||
})
|
||||
|
||||
it('does NOT snap when deviation exceeds threshold', () => {
|
||||
// (100, 40) — deviation from 0° is ~40px > 15 → no snap
|
||||
const pos = { x: 100, y: 40 }
|
||||
const r = snap45({ x: 0, y: 0 }, pos)
|
||||
expect(r).toEqual(pos)
|
||||
})
|
||||
|
||||
it('returns pos unchanged when distance < 1', () => {
|
||||
const pos = { x: 5, y: 5 }
|
||||
expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos)
|
||||
})
|
||||
|
||||
it('preserves distance from origin when snapping', () => {
|
||||
const from = { x: 0, y: 0 }
|
||||
const pos = { x: 100, y: 3 } // close to horizontal
|
||||
const r = snap45(from, pos)
|
||||
const origDist = Math.hypot(pos.x - from.x, pos.y - from.y)
|
||||
const snapDist = Math.hypot(r.x - from.x, r.y - from.y)
|
||||
expect(snapDist).toBeCloseTo(origDist, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snap45both', () => {
|
||||
it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => {
|
||||
// prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both
|
||||
const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 })
|
||||
// Result must be on a 45°-ray from (0,0)
|
||||
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
|
||||
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
|
||||
// Result must be on a 45°-ray from (100,100)
|
||||
const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4)
|
||||
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
|
||||
})
|
||||
|
||||
it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => {
|
||||
// prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each)
|
||||
// pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100)
|
||||
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 })
|
||||
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
|
||||
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
|
||||
const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4)
|
||||
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
|
||||
})
|
||||
|
||||
it('returns raw pos when beyond threshold', () => {
|
||||
// pos=(100,80) is 20px from nearest intersection (100,100) → no snap
|
||||
const pos = { x: 100, y: 80 }
|
||||
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos)
|
||||
expect(r).toEqual(pos)
|
||||
})
|
||||
|
||||
it('falls back gracefully when prev === next', () => {
|
||||
// No valid intersection → fallback to snap45
|
||||
const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 })
|
||||
expect(r).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('distToSegment', () => {
|
||||
it('returns 0 when point is on the segment', () => {
|
||||
expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('returns perpendicular distance when point is beside segment', () => {
|
||||
expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10)
|
||||
})
|
||||
|
||||
it('returns distance to nearest endpoint when point is past the segment', () => {
|
||||
expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('handles zero-length segment (a === b)', () => {
|
||||
expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findInsertIndex', () => {
|
||||
it('returns 0 when there are no waypoints (only one segment)', () => {
|
||||
expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0)
|
||||
})
|
||||
|
||||
it('inserts before first waypoint when click is on first segment', () => {
|
||||
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 })
|
||||
expect(idx).toBe(0)
|
||||
})
|
||||
|
||||
it('inserts after first waypoint when click is on second segment', () => {
|
||||
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 })
|
||||
expect(idx).toBe(1)
|
||||
})
|
||||
|
||||
it('picks the closest segment among multiple', () => {
|
||||
const idx = findInsertIndex(
|
||||
0, 0,
|
||||
[{ x: 100, y: 0 }, { x: 100, y: 100 }],
|
||||
200, 100,
|
||||
{ x: 150, y: 105 },
|
||||
)
|
||||
expect(idx).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} from '@xyflow/react'
|
||||
import type { EdgeData, EdgeType } from '@/types'
|
||||
import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
|
||||
|
||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||
|
||||
@@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string {
|
||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||
}
|
||||
|
||||
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
||||
|
||||
interface WaypointHandleProps {
|
||||
edgeId: string
|
||||
index: number
|
||||
waypoint: Waypoint
|
||||
waypoints: Waypoint[]
|
||||
color: string
|
||||
pathStyle?: string
|
||||
prevPoint: Waypoint
|
||||
nextPoint: Waypoint
|
||||
}
|
||||
|
||||
function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) {
|
||||
const { screenToFlowPosition } = useReactFlow()
|
||||
const updateEdge = useCanvasStore((s) => s.updateEdge)
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (e.buttons !== 1) return
|
||||
let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY })
|
||||
if (pathStyle === 'smooth') {
|
||||
// Find the intersection of 45°-rays from both adjacent points so that
|
||||
// ALL segments (prev→this and this→next) snap to 45° simultaneously.
|
||||
pos = snap45both(prevPoint, nextPoint, pos)
|
||||
}
|
||||
const next = [...waypoints]
|
||||
next[index] = pos
|
||||
updateEdge(edgeId, { waypoints: next })
|
||||
}, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint])
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) })
|
||||
}, [edgeId, waypoints, index, updateEdge])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${waypoint.x}px, ${waypoint.y}px)`,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
border: '2px solid #0d1117',
|
||||
cursor: 'grab',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title="Drag to move · Double-click to remove"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add waypoint handle (+ button at segment midpoints) ──────────────────────
|
||||
|
||||
interface AddWaypointHandleProps {
|
||||
edgeId: string
|
||||
insertIndex: number
|
||||
x: number
|
||||
y: number
|
||||
waypoints: Waypoint[]
|
||||
color: string
|
||||
pathStyle?: string
|
||||
prevPoint: Waypoint
|
||||
}
|
||||
|
||||
function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) {
|
||||
const updateEdge = useCanvasStore((s) => s.updateEdge)
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
let pos = { x, y }
|
||||
if (pathStyle === 'smooth') pos = snap45(prevPoint, pos)
|
||||
const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)]
|
||||
updateEdge(edgeId, { waypoints: next })
|
||||
}, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint])
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
background: '#0d1117',
|
||||
border: `1.5px solid ${color}`,
|
||||
color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
cursor: 'crosshair',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 9,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
title="Click to add waypoint"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Segment midpoints ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute + handle positions for each path segment.
|
||||
* For smooth style: bias the first + handle to the source handle axis and the
|
||||
* last + handle to the target handle axis, so clicking always gives a clean
|
||||
* perpendicular exit/entry (no diagonal guesswork near the nodes).
|
||||
*/
|
||||
function segmentMidpoints(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
pathStyle?: string,
|
||||
sourcePosition?: string,
|
||||
): { x: number; y: number; insertIndex: number }[] {
|
||||
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
const isSmooth = pathStyle === 'smooth'
|
||||
|
||||
return pts.slice(0, -1).map((a, i) => {
|
||||
const b = pts[i + 1]
|
||||
let mx = (a.x + b.x) / 2
|
||||
const my = (a.y + b.y) / 2
|
||||
|
||||
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||
// the source handle axis so clicking it creates a perpendicular exit.
|
||||
// Only applies to bottom/top handles (vertical exits) and only when the edge
|
||||
// has no waypoints yet — once waypoints exist, all + handles stay at the
|
||||
// real segment midpoint so they remain visually on the edge.
|
||||
if (isSmooth && i === 0 && pts.length === 2) {
|
||||
const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top'
|
||||
if (vertSrc) mx = a.x // same X as source → + sits directly below/above node
|
||||
}
|
||||
|
||||
return { x: mx, y: my, insertIndex: i }
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main edge component ──────────────────────────────────────────────────────
|
||||
|
||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||
|
||||
const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0
|
||||
? data.waypoints as Waypoint[]
|
||||
: []
|
||||
|
||||
const hasWaypoints = waypoints.length > 0
|
||||
|
||||
const pathStyle = data?.path_style as string | undefined
|
||||
|
||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||
const [edgePath, labelX] = data?.path_style === 'smooth'
|
||||
const [autoPath, labelX] = pathStyle === 'smooth'
|
||||
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
||||
: getBezierPath(pathArgs)
|
||||
|
||||
const edgePath = hasWaypoints
|
||||
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||
: autoPath
|
||||
|
||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
||||
const midY = (sourceY + targetY) / 2
|
||||
|
||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||
const edgeColors = theme.colors.edgeColors
|
||||
|
||||
@@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
}
|
||||
|
||||
const customColor = data?.custom_color as string | undefined
|
||||
const strokeColor: string = selected
|
||||
? theme.colors.edgeSelectedColor
|
||||
: customColor
|
||||
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...BASE_STYLES[edgeType],
|
||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
||||
@@ -50,16 +233,36 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||
}
|
||||
|
||||
// Normalize animated value — supports legacy boolean (true → 'snake')
|
||||
const animMode: 'none' | 'snake' | 'flow' =
|
||||
const animMode: 'none' | 'snake' | 'flow' | 'basic' =
|
||||
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
||||
data?.animated === 'flow' ? 'flow' : 'none'
|
||||
data?.animated === 'flow' ? 'flow' :
|
||||
data?.animated === 'basic' ? 'basic' : 'none'
|
||||
|
||||
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||
|
||||
const midpoints = selected
|
||||
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
|
||||
|
||||
{animMode === 'basic' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={style.strokeWidth as number ?? 2}
|
||||
strokeDasharray="5"
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
animation: 'homelable-basic-dash 0.5s linear infinite',
|
||||
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{animMode === 'snake' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
@@ -92,12 +295,12 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
</path>
|
||||
)}
|
||||
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<EdgeLabelRenderer>
|
||||
{data?.label && (
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}px)`,
|
||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
||||
background: theme.colors.edgeLabelBackground,
|
||||
color: theme.colors.edgeLabelColor,
|
||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||
@@ -105,8 +308,47 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
>
|
||||
{data.label as string}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Existing waypoint drag handles */}
|
||||
{selected && waypoints.map((wp, idx) => {
|
||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||
const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1]
|
||||
return (
|
||||
<WaypointHandle
|
||||
key={`wp-${idx}`}
|
||||
edgeId={id}
|
||||
index={idx}
|
||||
waypoint={wp}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
nextPoint={nextPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* + handles at segment midpoints to add new waypoints */}
|
||||
{selected && midpoints.map((mp) => {
|
||||
const prevPoint = mp.insertIndex === 0
|
||||
? { x: sourceX, y: sourceY }
|
||||
: waypoints[mp.insertIndex - 1]
|
||||
return (
|
||||
<AddWaypointHandle
|
||||
key={`add-${mp.insertIndex}`}
|
||||
edgeId={id}
|
||||
insertIndex={mp.insertIndex}
|
||||
x={mp.x}
|
||||
y={mp.y}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Waypoint } from '@/types'
|
||||
|
||||
// ── Path builders ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Catmull-Rom → cubic bezier for smooth curves through waypoints */
|
||||
function buildCatmullRomPath(pts: Waypoint[]): string {
|
||||
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(i - 1, 0)]
|
||||
const p1 = pts[i]
|
||||
const p2 = pts[i + 1]
|
||||
const p3 = pts[Math.min(i + 2, pts.length - 1)]
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||||
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */
|
||||
function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string {
|
||||
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||
if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}`
|
||||
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const prev = pts[i - 1]
|
||||
const curr = pts[i]
|
||||
const next = pts[i + 1]
|
||||
|
||||
const dx1 = curr.x - prev.x
|
||||
const dy1 = curr.y - prev.y
|
||||
const len1 = Math.hypot(dx1, dy1)
|
||||
|
||||
const dx2 = next.x - curr.x
|
||||
const dy2 = next.y - curr.y
|
||||
const len2 = Math.hypot(dx2, dy2)
|
||||
|
||||
if (len1 < 1 || len2 < 1) {
|
||||
d += ` L ${curr.x} ${curr.y}`
|
||||
continue
|
||||
}
|
||||
|
||||
const r = Math.min(radius, len1 / 2, len2 / 2)
|
||||
|
||||
// Approach point (on segment prev→curr, r units before corner)
|
||||
const bx = curr.x - (dx1 / len1) * r
|
||||
const by = curr.y - (dy1 / len1) * r
|
||||
|
||||
// Departure point (on segment curr→next, r units after corner)
|
||||
const ax = curr.x + (dx2 / len2) * r
|
||||
const ay = curr.y + (dy2 / len2) * r
|
||||
|
||||
d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}`
|
||||
}
|
||||
|
||||
d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`
|
||||
return d
|
||||
}
|
||||
|
||||
export function buildWaypointPath(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
pathStyle: string = 'bezier',
|
||||
): string {
|
||||
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||
}
|
||||
|
||||
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Snap `pos` to the nearest 45°-multiple direction from `from`.
|
||||
* Only snaps when within SNAP_THRESHOLD px of a 45° position.
|
||||
*/
|
||||
export function snap45(from: Waypoint, pos: Waypoint): Waypoint {
|
||||
const dx = pos.x - from.x
|
||||
const dy = pos.y - from.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist < 1) return pos
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||
const candidate = {
|
||||
x: Math.round(from.x + dist * Math.cos(snapped)),
|
||||
y: Math.round(from.y + dist * Math.sin(snapped)),
|
||||
}
|
||||
const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y)
|
||||
return deviation <= SNAP_THRESHOLD ? candidate : pos
|
||||
}
|
||||
|
||||
/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */
|
||||
const SNAP_THRESHOLD = 15
|
||||
|
||||
/**
|
||||
* Find the position closest to `pos` that lies simultaneously on a 45°-ray
|
||||
* from `prev` AND on a 45°-ray from `next`.
|
||||
*
|
||||
* Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px —
|
||||
* outside that zone the raw drag position is returned, allowing free placement.
|
||||
*/
|
||||
export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint {
|
||||
let best: Waypoint | null = null
|
||||
let bestDist = Infinity
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a1 = i * Math.PI / 4
|
||||
const c1 = Math.cos(a1), s1 = Math.sin(a1)
|
||||
|
||||
for (let j = 0; j < 8; j++) {
|
||||
const a2 = j * Math.PI / 4
|
||||
const c2 = Math.cos(a2), s2 = Math.sin(a2)
|
||||
|
||||
const dx = next.x - prev.x
|
||||
const dy = next.y - prev.y
|
||||
const det = -c1 * s2 + c2 * s1
|
||||
if (Math.abs(det) < 1e-6) continue
|
||||
|
||||
const t = (-dx * s2 + c2 * dy) / det
|
||||
const s = (c1 * dy - s1 * dx) / det
|
||||
if (t < -1e-6 || s < -1e-6) continue
|
||||
|
||||
const ix = prev.x + t * c1
|
||||
const iy = prev.y + t * s1
|
||||
const d = Math.hypot(ix - pos.x, iy - pos.y)
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
best = { x: Math.round(ix), y: Math.round(iy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only snap if close enough — otherwise let the waypoint move freely
|
||||
if (best === null || bestDist > SNAP_THRESHOLD) return pos
|
||||
return best
|
||||
}
|
||||
|
||||
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const lenSq = dx * dx + dy * dy
|
||||
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
|
||||
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
|
||||
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
|
||||
}
|
||||
|
||||
export function findInsertIndex(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
point: Waypoint,
|
||||
): number {
|
||||
const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
let minDist = Infinity
|
||||
let best = 0
|
||||
for (let i = 0; i < allPts.length - 1; i++) {
|
||||
const d = distToSegment(point, allPts[i], allPts[i + 1])
|
||||
if (d < minDist) { minDist = d; best = i }
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { createElement, useEffect, useMemo } from 'react'
|
||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp } from '@/utils/maskIp'
|
||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||
|
||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||
@@ -23,6 +24,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||
|
||||
const { zoom } = useViewport()
|
||||
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -31,19 +35,25 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
const statusColor = theme.colors.statusColors[data.status]
|
||||
const isOnline = data.status === 'online'
|
||||
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||
|
||||
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
||||
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
||||
const showLegacyHardware = !data.properties && data.show_hardware &&
|
||||
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
||||
style={{
|
||||
background: colors.background,
|
||||
borderColor: colors.border,
|
||||
borderWidth: selected ? 2 : 1,
|
||||
boxShadow: isOnline
|
||||
borderWidth,
|
||||
boxShadow: isOnline && selected
|
||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||
: isOnline
|
||||
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||
: selected
|
||||
? `0 0 8px ${colors.border}44`
|
||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||
: 'none',
|
||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||
minWidth: 140,
|
||||
@@ -55,7 +65,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
isVisible={selected}
|
||||
minWidth={140}
|
||||
minHeight={50}
|
||||
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||
/>
|
||||
<Handle
|
||||
@@ -67,7 +77,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
|
||||
{/* Main row */}
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
@@ -88,24 +98,43 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
{data.ip && (
|
||||
{data.ip && splitIps(data.ip).map((ip) => (
|
||||
<div
|
||||
key={ip}
|
||||
className="font-mono text-[10px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
title={data.ip}
|
||||
title={ip}
|
||||
>
|
||||
{hideIp ? maskIp(data.ip) : data.ip}
|
||||
{hideIp ? maskIp(ip) : ip}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware section */}
|
||||
{showHardware && (
|
||||
{/* Properties section (new system) */}
|
||||
{visibleProperties && visibleProperties.length > 0 && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||
{visibleProperties.map((prop) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
||||
{showLegacyHardware && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{/* Line 1: CPU */}
|
||||
{(data.cpu_model || data.cpu_count != null) && (
|
||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<Cpu size={9} className="shrink-0" />
|
||||
@@ -117,7 +146,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Line 2: RAM + Disk */}
|
||||
{(data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{data.ram_gb != null && (
|
||||
|
||||
@@ -66,13 +66,13 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
className="nodrag"
|
||||
>
|
||||
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="nodrag"
|
||||
value={labelDraft}
|
||||
onChange={(e) => setLabelDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -97,11 +97,12 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||
<button className="nodrag" onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||
<button className="nodrag" onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="nodrag"
|
||||
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
||||
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
||||
title="Rename group"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { useState } from 'react'
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData, TextPosition } from '@/types'
|
||||
|
||||
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
||||
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
||||
}
|
||||
|
||||
const HANDLE_SIDES = [
|
||||
{ id: 'zone-top', position: Position.Top },
|
||||
{ id: 'zone-right', position: Position.Right },
|
||||
{ id: 'zone-bottom', position: Position.Bottom },
|
||||
{ id: 'zone-left', position: Position.Left },
|
||||
] as const
|
||||
|
||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
whiteSpace: 'pre-wrap',
|
||||
}
|
||||
|
||||
const handleStyle: React.CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: borderColor,
|
||||
border: '2px solid #0d1117',
|
||||
borderRadius: '50%',
|
||||
opacity: hovered ? 1 : 0,
|
||||
transition: 'opacity 0.15s',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer
|
||||
@@ -73,8 +92,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
background: '#00d4ff',
|
||||
border: '1px solid #0d1117',
|
||||
}}
|
||||
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
/>
|
||||
|
||||
{HANDLE_SIDES.map(({ id: hid, position }) => (
|
||||
<span key={hid}>
|
||||
<Handle type="source" id={hid} position={position} style={handleStyle} />
|
||||
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
|
||||
</span>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -86,11 +113,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
justifyContent: posStyle.justifyContent,
|
||||
padding: 12,
|
||||
background: backgroundColor,
|
||||
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||
border: `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||
borderRadius: 10,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingGroupRectId(id)
|
||||
|
||||
@@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
||||
|
||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
||||
|
||||
type AnimMode = 'none' | 'snake' | 'flow'
|
||||
type AnimMode = 'none' | 'basic' | 'snake' | 'flow'
|
||||
|
||||
function toAnimMode(v: EdgeData['animated']): AnimMode {
|
||||
if (v === true || v === 'snake') return 'snake'
|
||||
if (v === 'flow') return 'flow'
|
||||
if (v === 'basic') return 'basic'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
@@ -23,11 +24,12 @@ interface EdgeModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: EdgeData) => void
|
||||
onDelete?: () => void
|
||||
onClearWaypoints?: () => void
|
||||
initial?: Partial<EdgeData>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
||||
const [label, setLabel] = useState(initial?.label ?? '')
|
||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||
@@ -126,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
||||
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
@@ -135,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
style={{
|
||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||
borderRight: i < 2 ? '1px solid #30363d' : undefined,
|
||||
borderRight: i < 3 ? '1px solid #30363d' : undefined,
|
||||
}}
|
||||
>
|
||||
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
|
||||
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -175,6 +177,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClearWaypoints(); onClose() }}
|
||||
className="text-[10px] text-muted-foreground hover:text-[#e3b341] transition-colors text-left"
|
||||
>
|
||||
Clear path ({initial.waypoints.length} point{initial.waypoints.length !== 1 ? 's' : ''})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete ? (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
||||
|
||||
@@ -49,8 +49,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
|
||||
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
@@ -211,11 +209,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
|
||||
{/* IP */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
||||
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
||||
<Input
|
||||
value={form.ip ?? ''}
|
||||
onChange={(e) => set('ip', e.target.value)}
|
||||
placeholder="192.168.1.x"
|
||||
placeholder="192.168.1.x, 2001:db8::1"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -334,88 +332,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hardware specs (hidden for groupRect) */}
|
||||
{form.type !== 'groupRect' && (
|
||||
<div className="flex flex-col gap-2 col-span-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHardwareOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="font-medium">Hardware</span>
|
||||
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{hardwareOpen && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.show_hardware}
|
||||
onClick={() => set('show_hardware', !form.show_hardware)}
|
||||
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hardwareOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">CPU Model</Label>
|
||||
<Input
|
||||
value={form.cpu_model ?? ''}
|
||||
onChange={(e) => set('cpu_model', e.target.value || undefined)}
|
||||
placeholder="e.g. Intel Xeon E5-2680"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.cpu_count ?? ''}
|
||||
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
placeholder="e.g. 8"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={form.ram_gb ?? ''}
|
||||
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="e.g. 32"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={form.disk_gb ?? ''}
|
||||
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="e.g. 500"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom connection points (not for group containers) */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
|
||||
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
|
||||
})
|
||||
|
||||
it('selecting Basic sends animated: "basic"', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Basic'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||
})
|
||||
|
||||
it('pre-fills animation from initial "basic" string', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||
})
|
||||
|
||||
it('selecting None after Snake omits animated from payload', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
@@ -187,4 +202,55 @@ describe('EdgeModal', () => {
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Waypoints / Clear path ────────────────────────────────────────────────
|
||||
|
||||
it('does not show Clear path button when onClearWaypoints is not provided', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when waypoints are empty', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} initial={{ type: 'ethernet', waypoints: [] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when no initial waypoints', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Clear path button with count when waypoints exist', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Clear path (2 points)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows singular "point" when only one waypoint', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Clear path (1 point)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onClearWaypoints and onClose when Clear path is clicked', () => {
|
||||
const onClearWaypoints = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={onClose} onSubmit={vi.fn()} onClearWaypoints={onClearWaypoints}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Clear path (1 point)'))
|
||||
expect(onClearWaypoints).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('NodeModal', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
|
||||
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan')
|
||||
expect((screen.getByPlaceholderText('192.168.1.x') as HTMLInputElement).value).toBe('192.168.1.10')
|
||||
expect((screen.getByPlaceholderText('192.168.1.x, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10')
|
||||
})
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────
|
||||
@@ -121,7 +121,7 @@ describe('NodeModal', () => {
|
||||
it('submits updated hostname, IP and notes', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { target: { value: '10.0.0.1' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x, 2001:db8::1'), { target: { value: '10.0.0.1' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
@@ -293,91 +293,6 @@ describe('NodeModal', () => {
|
||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Hardware section ──────────────────────────────────────────────────
|
||||
|
||||
it('renders Hardware toggle button', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hardware fields are hidden by default', () => {
|
||||
renderModal()
|
||||
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands hardware fields on toggle click', () => {
|
||||
renderModal()
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
||||
})
|
||||
|
||||
it('auto-expands when initial has hardware data', () => {
|
||||
renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } })
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('pre-fills hardware fields from initial', () => {
|
||||
renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } })
|
||||
expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5')
|
||||
})
|
||||
|
||||
it('submits hardware fields when filled', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(data.cpu_model).toBe('Intel i7-12700K')
|
||||
expect(data.cpu_count).toBe(12)
|
||||
expect(data.ram_gb).toBe(64)
|
||||
expect(data.disk_gb).toBe(2000)
|
||||
})
|
||||
|
||||
it('hides Hardware section for groupRect type', () => {
|
||||
renderModal({ initial: { type: 'groupRect' } })
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
})
|
||||
|
||||
it('show_hardware toggle hidden when section is collapsed', () => {
|
||||
renderModal()
|
||||
expect(screen.queryByText('Show on node')).toBeNull()
|
||||
})
|
||||
|
||||
it('show_hardware toggle appears when section is expanded', () => {
|
||||
renderModal()
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByText('Show on node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('show_hardware defaults to falsy', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||
})
|
||||
|
||||
it('toggling show_hardware sets it to true', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-fills show_hardware from initial', () => {
|
||||
const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
// ── Bottom connection points ───────────────────────────────────────────
|
||||
|
||||
it('shows Bottom Connection Points for server type', () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { createElement, useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { primaryIp } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
@@ -14,6 +16,9 @@ interface DetailPanelProps {
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||
|
||||
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
||||
|
||||
@@ -24,6 +29,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||
|
||||
// Properties state
|
||||
const [addingProp, setAddingProp] = useState(false)
|
||||
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
||||
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
||||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||
|
||||
// Multi-select panel
|
||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||
|
||||
@@ -119,6 +130,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
setEditingFor(null)
|
||||
}
|
||||
|
||||
// --- Property handlers ---
|
||||
const properties: NodeProperty[] = data.properties ?? []
|
||||
|
||||
const handleAddProp = () => {
|
||||
if (!newProp.key.trim() || !newProp.value.trim()) return
|
||||
snapshotHistory()
|
||||
const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
|
||||
updateNode(node.id, { properties: [...properties, prop] })
|
||||
setNewProp(EMPTY_PROP)
|
||||
setAddingProp(false)
|
||||
}
|
||||
|
||||
const handleRemoveProp = (index: number) => {
|
||||
snapshotHistory()
|
||||
updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
|
||||
if (editingPropIndex === index) setEditingPropIndex(null)
|
||||
}
|
||||
|
||||
const handleTogglePropVisible = (index: number) => {
|
||||
snapshotHistory()
|
||||
updateNode(node.id, {
|
||||
properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
|
||||
})
|
||||
}
|
||||
|
||||
const handleStartEditProp = (index: number) => {
|
||||
const p = properties[index]
|
||||
if (!p) return
|
||||
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
|
||||
setEditingPropIndex(index)
|
||||
setAddingProp(false)
|
||||
}
|
||||
|
||||
const handleSaveEditProp = () => {
|
||||
if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
|
||||
snapshotHistory()
|
||||
updateNode(node.id, {
|
||||
properties: properties.map((p, i) =>
|
||||
i === editingPropIndex
|
||||
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
|
||||
: p
|
||||
),
|
||||
})
|
||||
setEditingPropIndex(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
@@ -146,22 +203,68 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||
{data.ip && (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
||||
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
|
||||
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
|
||||
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
|
||||
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
|
||||
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
|
||||
{/* Properties section */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||
<button
|
||||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{addingProp && (
|
||||
<PropertyForm
|
||||
form={newProp}
|
||||
onChange={setNewProp}
|
||||
onConfirm={handleAddProp}
|
||||
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
|
||||
confirmLabel="Add"
|
||||
/>
|
||||
)}
|
||||
{properties.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{properties.map((prop, i) =>
|
||||
editingPropIndex === i ? (
|
||||
<PropertyForm
|
||||
key={`edit-${i}`}
|
||||
form={editProp}
|
||||
onChange={setEditProp}
|
||||
onConfirm={handleSaveEditProp}
|
||||
onCancel={() => setEditingPropIndex(null)}
|
||||
confirmLabel="Save"
|
||||
/>
|
||||
) : (
|
||||
<PropertyBadge
|
||||
key={`${prop.key}-${i}`}
|
||||
prop={prop}
|
||||
onToggleVisible={() => handleTogglePropVisible(i)}
|
||||
onEdit={() => handleStartEditProp(i)}
|
||||
onRemove={() => handleRemoveProp(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{properties.length === 0 && !addingProp && (
|
||||
<p className="text-[10px] text-muted-foreground/50">No properties — click Add to define one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -365,11 +468,6 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatStorage(gb: number): string {
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||
return `${gb} GB`
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
@@ -407,6 +505,113 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
|
||||
)
|
||||
}
|
||||
|
||||
// --- Property components ---
|
||||
|
||||
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
||||
form: PropForm
|
||||
onChange: (f: PropForm) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input
|
||||
value={form.key}
|
||||
onChange={(e) => onChange({ ...form, key: e.target.value })}
|
||||
placeholder="Label (e.g. CPU Model)"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<Input
|
||||
value={form.value}
|
||||
onChange={(e) => onChange({ ...form, value: e.target.value })}
|
||||
placeholder="Value (e.g. i7-12700K)"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
{/* Icon picker */}
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
<button
|
||||
onClick={() => onChange({ ...form, icon: null })}
|
||||
title="No icon"
|
||||
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
|
||||
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||
}`}
|
||||
>
|
||||
–
|
||||
</button>
|
||||
{PROPERTY_ICON_NAMES.map((name) => {
|
||||
const Icon = PROPERTY_ICONS[name]
|
||||
const active = form.icon === name
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => onChange({ ...form, icon: name })}
|
||||
title={name}
|
||||
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
|
||||
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||
}`}
|
||||
>
|
||||
{createElement(Icon, { size: 11 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Visible toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.visible}
|
||||
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
|
||||
className="accent-[#00d4ff] w-3 h-3"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Show on node</span>
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
|
||||
prop: NodeProperty
|
||||
onToggleVisible: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d' }}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
||||
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onToggleVisible}
|
||||
title={prop.visible ? 'Hide on node' : 'Show on node'}
|
||||
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
|
||||
>
|
||||
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
|
||||
</button>
|
||||
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
@@ -165,9 +165,26 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
||||
const { addNode, scanEventTs } = useCanvasStore()
|
||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const allChecked = devices.length > 0 && checkedIds.size === devices.length
|
||||
const someChecked = checkedIds.size > 0
|
||||
|
||||
const toggleCheck = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -184,12 +201,58 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
try {
|
||||
await scanApi.clearPending()
|
||||
setDevices([])
|
||||
setCheckedIds(new Set())
|
||||
toast.success('Pending devices cleared')
|
||||
} catch {
|
||||
toast.error('Failed to clear pending devices')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
const ids = [...checkedIds]
|
||||
try {
|
||||
const res = await scanApi.bulkApprove(ids)
|
||||
const deviceToNode: Record<string, string> = {}
|
||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||
approvedDevices.forEach((d, i) => {
|
||||
const nodeId = deviceToNode[d.id]
|
||||
if (!nodeId) return
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||
data: {
|
||||
label: d.hostname ?? d.ip,
|
||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||
ip: d.ip,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: 'unknown' as const,
|
||||
services: (d.services ?? []) as import('@/types').ServiceInfo[],
|
||||
},
|
||||
})
|
||||
onNodeApproved(nodeId)
|
||||
})
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setCheckedIds(new Set())
|
||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk approve devices')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkHide = async () => {
|
||||
const ids = [...checkedIds]
|
||||
try {
|
||||
const res = await scanApi.bulkHide(ids)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setCheckedIds(new Set())
|
||||
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk hide devices')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -251,7 +314,19 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
<>
|
||||
<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">Pending</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{devices.length > 0 && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
|
||||
onChange={toggleAll}
|
||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||
title="Select all"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
||||
<RefreshCw size={12} />
|
||||
@@ -263,6 +338,22 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{someChecked && (
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
|
||||
>
|
||||
Approve ({checkedIds.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkHide}
|
||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
|
||||
>
|
||||
Hide ({checkedIds.size})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && devices.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||
@@ -288,10 +379,16 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
key={d.id}
|
||||
ref={isHighlighted ? highlightRef : null}
|
||||
onClick={() => setSelected(d)}
|
||||
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
||||
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedIds.has(d.id)}
|
||||
onClick={(e) => toggleCheck(d.id, e)}
|
||||
onChange={() => {}}
|
||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||
/>
|
||||
<span className="text-foreground truncate font-medium">{title}</span>
|
||||
</div>
|
||||
{showIpBelow && (
|
||||
|
||||
@@ -69,58 +69,137 @@ describe('DetailPanel', () => {
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('Hardware section', () => {
|
||||
it('does not render hardware section when no hardware data', () => {
|
||||
setupStore({ label: 'Server' })
|
||||
describe('Properties section', () => {
|
||||
it('renders empty state when no properties', () => {
|
||||
setupStore({ properties: [] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
expect(screen.getByText(/No properties/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders hardware section when cpu_count is set', () => {
|
||||
setupStore({ cpu_count: 8 })
|
||||
it('renders properties with key and value', () => {
|
||||
setupStore({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: false },
|
||||
],
|
||||
})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('8')).toBeDefined()
|
||||
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders cpu_model', () => {
|
||||
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
|
||||
it('shows Properties count when properties exist', () => {
|
||||
setupStore({
|
||||
properties: [
|
||||
{ key: 'CPU Model', value: 'i7', icon: null, visible: true },
|
||||
{ key: 'RAM', value: '16 GB', icon: null, visible: true },
|
||||
],
|
||||
})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByText('Properties (2)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb in GB', () => {
|
||||
setupStore({ ram_gb: 32 })
|
||||
it('shows add form when Add is clicked', () => {
|
||||
setupStore({ properties: [] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('32 GB')).toBeDefined()
|
||||
// There are multiple "Add" buttons (services + properties) — find the one after "Properties"
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0]) // first Add = properties (rendered above services)
|
||||
expect(screen.getByPlaceholderText('Label (e.g. CPU Model)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb >= 1024 as TB', () => {
|
||||
setupStore({ ram_gb: 2048 })
|
||||
it('calls updateNode with new property on Add confirm', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('2 TB')).toBeDefined()
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0]) // first Add = properties
|
||||
// Form is now open — fill key and value
|
||||
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
|
||||
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
|
||||
})
|
||||
|
||||
it('formats disk_gb in GB', () => {
|
||||
setupStore({ disk_gb: 500 })
|
||||
it('calls updateNode with toggled visibility when eye button is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('500 GB')).toBeDefined()
|
||||
fireEvent.click(screen.getByTitle('Hide on node'))
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties[0].visible).toBe(false)
|
||||
})
|
||||
|
||||
it('formats disk_gb >= 1024 as TB', () => {
|
||||
setupStore({ disk_gb: 1536 })
|
||||
it('calls updateNode without the property when remove button is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [{ key: 'GPU', value: 'RTX 4090', icon: null, visible: true }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('1.5 TB')).toBeDefined()
|
||||
fireEvent.click(screen.getByTitle('Remove property'))
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders all hardware fields together', () => {
|
||||
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
|
||||
it('does not submit add form when key is empty', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ properties: [] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('AMD EPYC')).toBeDefined()
|
||||
expect(screen.getByText('16')).toBeDefined()
|
||||
expect(screen.getByText('128 GB')).toBeDefined()
|
||||
expect(screen.getByText('4 TB')).toBeDefined()
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0])
|
||||
// Only fill value, leave key empty
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
|
||||
const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1])
|
||||
expect(updateNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,7 +268,9 @@ describe('DetailPanel', () => {
|
||||
it('shows add form when Add is clicked', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
// Two "Add" buttons: first = properties, second = services
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -198,18 +279,21 @@ describe('DetailPanel', () => {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
// Two "Add" header buttons: first = properties, second = services
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
|
||||
})
|
||||
@@ -301,4 +385,34 @@ describe('DetailPanel', () => {
|
||||
expect(screen.getByText('nginx')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IP Address — clickable link', () => {
|
||||
it('renders a link for a single IP', () => {
|
||||
setupStore({ ip: '192.168.1.10' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
|
||||
expect(link).toBeDefined()
|
||||
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('renders no IP link when ip is absent', () => {
|
||||
setupStore({ ip: undefined })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('IP Address')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses primary IP as href for comma-separated IPs', () => {
|
||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
|
||||
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
|
||||
})
|
||||
|
||||
it('displays full comma-separated IP string as link text', () => {
|
||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { NodeData } from '@/types'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
const mockBulkApprove = vi.fn()
|
||||
const mockBulkHide = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
trigger: vi.fn().mockResolvedValue({}),
|
||||
@@ -16,6 +19,12 @@ vi.mock('@/api/client', () => ({
|
||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
stop: vi.fn().mockResolvedValue({}),
|
||||
clearPending: vi.fn().mockResolvedValue({}),
|
||||
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
|
||||
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
|
||||
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
|
||||
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
|
||||
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
|
||||
},
|
||||
settingsApi: {
|
||||
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||
@@ -259,3 +268,100 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
|
||||
|
||||
const DEVICE_A = {
|
||||
id: 'dev-a',
|
||||
ip: '192.168.1.10',
|
||||
hostname: 'host-a',
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'generic',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
}
|
||||
|
||||
const DEVICE_B = {
|
||||
id: 'dev-b',
|
||||
ip: '192.168.1.11',
|
||||
hostname: 'host-b',
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'generic',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
}
|
||||
|
||||
describe('PendingDevicesPanel — bulk select', () => {
|
||||
beforeEach(() => {
|
||||
mockStore()
|
||||
vi.clearAllMocks()
|
||||
mockBulkApprove.mockResolvedValue({
|
||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
|
||||
})
|
||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||
})
|
||||
|
||||
async function renderWithDevices() {
|
||||
const { scanApi } = await import('@/api/client')
|
||||
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
|
||||
render(<Sidebar {...defaultProps} forceView="pending" />)
|
||||
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
|
||||
}
|
||||
|
||||
it('renders checkboxes for each device', async () => {
|
||||
await renderWithDevices()
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
// select-all + 2 device checkboxes
|
||||
expect(checkboxes.length).toBe(3)
|
||||
})
|
||||
|
||||
it('shows bulk action bar when a device is checked', async () => {
|
||||
await renderWithDevices()
|
||||
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(firstDeviceCheckbox)
|
||||
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
|
||||
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides bulk action bar when no device is checked', async () => {
|
||||
await renderWithDevices()
|
||||
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('select-all checks all devices', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll)
|
||||
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('select-all unchecks all when all are selected', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll) // select all
|
||||
fireEvent.click(selectAll) // deselect all
|
||||
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('calls bulkApprove with checked ids and removes devices from list', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll)
|
||||
fireEvent.click(screen.getByText(/Approve \(2\)/))
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('calls bulkHide with checked ids and removes devices from list', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll)
|
||||
fireEvent.click(screen.getByText(/Hide \(2\)/))
|
||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@keyframes homelable-basic-dash {
|
||||
from { stroke-dashoffset: 10; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
/* Homelable dark theme — always dark */
|
||||
:root {
|
||||
--background: #0d1117;
|
||||
|
||||
@@ -191,6 +191,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
let nodes = state.nodes.map((n) => {
|
||||
if (n.id !== id) return n
|
||||
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
||||
// When properties change, clear stored height so the node auto-sizes to fit new content
|
||||
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect') {
|
||||
updated.height = undefined
|
||||
}
|
||||
if ('parent_id' in data) {
|
||||
const newParentId = data.parent_id ?? undefined
|
||||
if (!newParentId && n.parentId) {
|
||||
|
||||
@@ -43,6 +43,13 @@ export interface ServiceInfo {
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface NodeProperty {
|
||||
key: string
|
||||
value: string
|
||||
icon: string | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export interface NodeData extends Record<string, unknown> {
|
||||
label: string
|
||||
type: NodeType
|
||||
@@ -62,6 +69,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
ram_gb?: number
|
||||
disk_gb?: number
|
||||
show_hardware?: boolean
|
||||
properties?: NodeProperty[]
|
||||
parent_id?: string
|
||||
container_mode?: boolean
|
||||
custom_colors?: {
|
||||
@@ -87,6 +95,11 @@ export interface NodeData extends Record<string, unknown> {
|
||||
|
||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||
|
||||
export interface Waypoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface EdgeData extends Record<string, unknown> {
|
||||
type: EdgeType
|
||||
label?: string
|
||||
@@ -94,7 +107,8 @@ export interface EdgeData extends Record<string, unknown> {
|
||||
speed?: string
|
||||
custom_color?: string
|
||||
path_style?: EdgePathStyle
|
||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
||||
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||
waypoints?: Waypoint[]
|
||||
}
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
|
||||
@@ -236,6 +236,36 @@ describe('serializeEdge', () => {
|
||||
expect(result.custom_color).toBeNull()
|
||||
expect(result.path_style).toBeNull()
|
||||
})
|
||||
|
||||
it('serializes waypoints when present', () => {
|
||||
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [{ x: 10, y: 20 }, { x: 30, y: 40 }] } })
|
||||
const result = serializeEdge(edge)
|
||||
expect(result.waypoints).toEqual([{ x: 10, y: 20 }, { x: 30, y: 40 }])
|
||||
})
|
||||
|
||||
it('serializes waypoints as null when empty array', () => {
|
||||
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [] } })
|
||||
const result = serializeEdge(edge)
|
||||
expect(result.waypoints).toBeNull()
|
||||
})
|
||||
|
||||
it('serializes waypoints as null when absent', () => {
|
||||
const result = serializeEdge(makeRfEdge())
|
||||
expect(result.waypoints).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deserializeApiEdge — waypoints', () => {
|
||||
it('restores waypoints from API edge', () => {
|
||||
const edge = makeApiEdge({ waypoints: [{ x: 5, y: 15 }, { x: 25, y: 35 }] })
|
||||
const result = deserializeApiEdge(edge)
|
||||
expect((result.data as { waypoints: unknown }).waypoints).toEqual([{ x: 5, y: 15 }, { x: 25, y: 35 }])
|
||||
})
|
||||
|
||||
it('has no waypoints when API edge has none', () => {
|
||||
const result = deserializeApiEdge(makeApiEdge())
|
||||
expect((result.data as { waypoints?: unknown }).waypoints).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── deserializeApiNode — regular nodes ───────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { maskIp } from '../maskIp'
|
||||
import { maskIp, splitIps, primaryIp } from '../maskIp'
|
||||
|
||||
describe('maskIp', () => {
|
||||
// IPv4
|
||||
it('masks last two octets of a standard IPv4', () => {
|
||||
expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX')
|
||||
})
|
||||
@@ -11,9 +12,61 @@ describe('maskIp', () => {
|
||||
expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX')
|
||||
})
|
||||
|
||||
it('passes through non-IPv4 strings unchanged', () => {
|
||||
// IPv6
|
||||
it('masks second group and last group of an IPv6 address', () => {
|
||||
expect(maskIp('2001:db8::1')).toBe('2001:XX::XX')
|
||||
})
|
||||
|
||||
it('masks a full IPv6 address', () => {
|
||||
expect(maskIp('fe80:0000:0000:0000:0202:b3ff:fe1e:8329')).toBe('fe80:XX:0000:0000:0202:b3ff:fe1e:XX')
|
||||
})
|
||||
|
||||
it('masks loopback IPv6', () => {
|
||||
// ::1 splits into ['', '', '1'] — groups[1] and last are masked
|
||||
expect(maskIp('::1')).toBe(':XX:XX')
|
||||
})
|
||||
|
||||
// Comma-separated
|
||||
it('masks all IPs in a comma-separated string', () => {
|
||||
expect(maskIp('192.168.1.1, 2001:db8::1')).toBe('192.168.XX.XX, 2001:XX::XX')
|
||||
})
|
||||
|
||||
it('handles comma-separated without spaces', () => {
|
||||
expect(maskIp('10.0.0.1,10.0.0.2')).toBe('10.0.XX.XX, 10.0.XX.XX')
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('passes through non-IP strings unchanged', () => {
|
||||
expect(maskIp('hostname')).toBe('hostname')
|
||||
expect(maskIp('fe80::1')).toBe('fe80::1')
|
||||
expect(maskIp('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitIps', () => {
|
||||
it('returns array of trimmed IPs', () => {
|
||||
expect(splitIps('192.168.1.1, 2001:db8::1')).toEqual(['192.168.1.1', '2001:db8::1'])
|
||||
})
|
||||
|
||||
it('returns single-element array for single IP', () => {
|
||||
expect(splitIps('10.0.0.1')).toEqual(['10.0.0.1'])
|
||||
})
|
||||
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(splitIps('')).toEqual([])
|
||||
expect(splitIps(' ')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('primaryIp', () => {
|
||||
it('returns first IP from comma-separated string', () => {
|
||||
expect(primaryIp('192.168.1.1, 2001:db8::1')).toBe('192.168.1.1')
|
||||
})
|
||||
|
||||
it('returns the only IP when single', () => {
|
||||
expect(primaryIp('10.0.0.1')).toBe('10.0.0.1')
|
||||
})
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(primaryIp('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Cpu, HardDrive, MemoryStick } from 'lucide-react'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
|
||||
|
||||
describe('PROPERTY_ICONS', () => {
|
||||
it('contains the hardware migration icons', () => {
|
||||
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
|
||||
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
|
||||
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
|
||||
})
|
||||
|
||||
it('has at least 10 icons', () => {
|
||||
expect(Object.keys(PROPERTY_ICONS).length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('every value is a renderable component (function or object)', () => {
|
||||
for (const [, icon] of Object.entries(PROPERTY_ICONS)) {
|
||||
// Lucide icons can be functions or forwardRef objects depending on environment
|
||||
expect(icon).toBeTruthy()
|
||||
expect(['function', 'object']).toContain(typeof icon)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PROPERTY_ICON_NAMES', () => {
|
||||
it('matches the keys of PROPERTY_ICONS', () => {
|
||||
expect(PROPERTY_ICON_NAMES).toEqual(expect.arrayContaining(Object.keys(PROPERTY_ICONS)))
|
||||
expect(PROPERTY_ICON_NAMES.length).toBe(Object.keys(PROPERTY_ICONS).length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePropertyIcon', () => {
|
||||
it('returns the icon for a known name', () => {
|
||||
expect(resolvePropertyIcon('Cpu')).toBe(Cpu)
|
||||
expect(resolvePropertyIcon('HardDrive')).toBe(HardDrive)
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(resolvePropertyIcon(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(resolvePropertyIcon(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unknown icon name', () => {
|
||||
expect(resolvePropertyIcon('NotARealIcon')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(resolvePropertyIcon('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -134,6 +134,73 @@ describe('Standalone localStorage save/load cycle', () => {
|
||||
expect(stored.data?.label).toBe('VLAN 20')
|
||||
})
|
||||
|
||||
it('preserves node properties through the round-trip', () => {
|
||||
const props = [
|
||||
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
|
||||
{ key: 'CPU', value: 'Intel i9', icon: 'Cpu', visible: false },
|
||||
]
|
||||
const nodes = [makeNode('n1', { data: { label: 'n1', type: 'server', status: 'unknown', services: [], properties: props } })]
|
||||
const raw = standaloneSerialize(nodes, [])
|
||||
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
|
||||
const stored = useCanvasStore.getState().nodes[0]
|
||||
expect(stored.data.properties).toEqual(props)
|
||||
})
|
||||
|
||||
it('preserves empty properties array through the round-trip', () => {
|
||||
const nodes = [makeNode('n1', { data: { label: 'n1', type: 'server', status: 'unknown', services: [], properties: [] } })]
|
||||
const raw = standaloneSerialize(nodes, [])
|
||||
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
|
||||
expect(useCanvasStore.getState().nodes[0].data.properties).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves edge waypoints through the round-trip', () => {
|
||||
const waypoints = [{ x: 100, y: 200 }, { x: 300, y: 150 }]
|
||||
const edges: Edge<EdgeData>[] = [{
|
||||
id: 'e1', source: 'n1', target: 'n2', type: 'ethernet',
|
||||
data: { type: 'ethernet', waypoints },
|
||||
}]
|
||||
const raw = standaloneSerialize([], edges)
|
||||
const { edges: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas([], loaded)
|
||||
|
||||
expect(useCanvasStore.getState().edges[0].data?.waypoints).toEqual(waypoints)
|
||||
})
|
||||
|
||||
it('preserves basic animation through the round-trip', () => {
|
||||
const edges: Edge<EdgeData>[] = [{
|
||||
id: 'e1', source: 'n1', target: 'n2', type: 'ethernet',
|
||||
data: { type: 'ethernet', animated: 'basic' },
|
||||
}]
|
||||
const raw = standaloneSerialize([], edges)
|
||||
const { edges: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas([], loaded)
|
||||
|
||||
expect(useCanvasStore.getState().edges[0].data?.animated).toBe('basic')
|
||||
})
|
||||
|
||||
it('preserves all three animation types through the round-trip', () => {
|
||||
const n1 = makeNode('n1')
|
||||
const n2 = makeNode('n2')
|
||||
const n3 = makeNode('n3')
|
||||
const edges: Edge<EdgeData>[] = [
|
||||
{ id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet', animated: 'snake' } },
|
||||
{ id: 'e2', source: 'n2', target: 'n3', type: 'ethernet', data: { type: 'ethernet', animated: 'flow' } },
|
||||
{ id: 'e3', source: 'n1', target: 'n3', type: 'ethernet', data: { type: 'ethernet', animated: 'basic' } },
|
||||
]
|
||||
const raw = standaloneSerialize([n1, n2, n3], edges)
|
||||
const { edges: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas([n1, n2, n3], loaded)
|
||||
|
||||
const stored = useCanvasStore.getState().edges
|
||||
expect(stored.find((e) => e.id === 'e1')?.data?.animated).toBe('snake')
|
||||
expect(stored.find((e) => e.id === 'e2')?.data?.animated).toBe('flow')
|
||||
expect(stored.find((e) => e.id === 'e3')?.data?.animated).toBe('basic')
|
||||
})
|
||||
|
||||
// ── loadCanvas marks clean ────────────────────────────────────────────────
|
||||
|
||||
it('loadCanvas sets hasUnsavedChanges to false', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||
import { normalizeHandle } from '@/utils/handleUtils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -28,6 +28,7 @@ export interface ApiNode extends Record<string, unknown> {
|
||||
ram_gb?: number | null
|
||||
disk_gb?: number | null
|
||||
show_hardware?: boolean
|
||||
properties?: unknown[] | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
bottom_handles?: number
|
||||
@@ -43,9 +44,10 @@ export interface ApiEdge {
|
||||
speed?: string | null
|
||||
custom_color?: string | null
|
||||
path_style?: string | null
|
||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
||||
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||
source_handle?: string | null
|
||||
target_handle?: string | null
|
||||
waypoints?: Waypoint[] | null
|
||||
}
|
||||
|
||||
// ── Serialization (RF node → API save payload) ───────────────────────────────
|
||||
@@ -99,8 +101,9 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
ram_gb: n.data.ram_gb ?? null,
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
width: n.width ?? null,
|
||||
height: n.height ?? null,
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.measured?.width ?? n.width ?? null,
|
||||
height: n.measured?.height ?? n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
@@ -121,6 +124,7 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
||||
animated: e.data?.animated ?? false,
|
||||
source_handle: normalizeHandle(e.sourceHandle),
|
||||
target_handle: normalizeHandle(e.targetHandle),
|
||||
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +156,8 @@ export function deserializeApiNode(
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n as unknown as NodeData,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
...(n.type === 'proxmox' && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
: {}),
|
||||
...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}),
|
||||
...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}),
|
||||
...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}),
|
||||
...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
/**
|
||||
* Mask the last two octets of an IPv4 address.
|
||||
* e.g. "192.168.1.115" → "192.168.XX.XX"
|
||||
* Non-IPv4 strings are returned unchanged.
|
||||
* Mask a single IP address:
|
||||
* - IPv4 "192.168.1.115" → "192.168.XX.XX"
|
||||
* - IPv6 "2001:db8::1" → "2001:XX::XX"
|
||||
* - Other strings returned unchanged.
|
||||
*/
|
||||
function maskSingle(ip: string): string {
|
||||
const trimmed = ip.trim()
|
||||
if (/^[\da-fA-F:]+$/.test(trimmed) && trimmed.includes(':')) {
|
||||
const groups = trimmed.split(':')
|
||||
if (groups.length >= 2) {
|
||||
groups[1] = 'XX'
|
||||
groups[groups.length - 1] = 'XX'
|
||||
return groups.join(':')
|
||||
}
|
||||
}
|
||||
const parts = trimmed.split('.')
|
||||
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask all IPs in a comma-separated string.
|
||||
* e.g. "192.168.1.1, 2001:db8::1" → "192.168.XX.XX, 2001:XX::XX"
|
||||
*/
|
||||
export function maskIp(ip: string): string {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
|
||||
return ip
|
||||
if (!ip) return ip
|
||||
return ip.split(',').map(maskSingle).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a comma-separated IP string into an array of trimmed values.
|
||||
* Empty string returns [].
|
||||
*/
|
||||
export function splitIps(ip: string): string[] {
|
||||
if (!ip?.trim()) return []
|
||||
return ip.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first IP from a comma-separated string (used for status checks).
|
||||
*/
|
||||
export function primaryIp(ip: string): string {
|
||||
return splitIps(ip)[0] ?? ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
}
|
||||
|
||||
export const PROPERTY_ICON_NAMES = Object.keys(PROPERTY_ICONS) as (keyof typeof PROPERTY_ICONS)[]
|
||||
|
||||
export function resolvePropertyIcon(name: string | null | undefined): LucideIcon | null {
|
||||
if (!name) return null
|
||||
return PROPERTY_ICONS[name] ?? null
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import pkg from './package.json'
|
||||
|
||||
const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim()
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
|
||||