Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c7f25a30 | |||
| 8bd1c48976 | |||
| 05c98355a6 | |||
| 323dea6798 | |||
| 19cb4b71f5 | |||
| fd86c0f6ad | |||
| 00d44abfad | |||
| 3bd18ab543 | |||
| 4d8bb246f1 | |||
| 07da498d18 | |||
| 0e59f15608 | |||
| bd22891fab | |||
| d96b502524 | |||
| 9cb9d02459 | |||
| 08a3a689c5 | |||
| 034ba599f4 | |||
| 38c5bcb606 | |||
| 0a72f9be67 | |||
| 041583c9b4 | |||
| 5a3e8ea0b1 | |||
| 70907e37bc | |||
| e356d433cb | |||
| cf7777e0af | |||
| 255443b8e1 | |||
| 7e24878077 | |||
| ff1bc7340d | |||
| a7c9abbb9a | |||
| e4bfab7e58 |
+5
-29
@@ -53,37 +53,13 @@ docker compose up -d
|
|||||||
|
|
||||||
## Proxmox LXC Install
|
## Proxmox LXC Install
|
||||||
|
|
||||||
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
You can now install Homelable with community-scripts (proxmox-VE) :
|
||||||
|
|
||||||
|
`https://community-scripts.org/scripts/homelable`
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
|
||||||
```
|
|
||||||
|
|
||||||
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
|
||||||
|
|
||||||
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
|
||||||
> ```bash
|
|
||||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### Update (LXC)
|
|
||||||
|
|
||||||
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash /opt/homelable/scripts/update.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly from GitHub:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -18,6 +20,16 @@ from app.services.scanner import request_cancel, run_scan
|
|||||||
class ScanConfig(BaseModel):
|
class ScanConfig(BaseModel):
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
|
|
||||||
|
@field_validator("ranges")
|
||||||
|
@classmethod
|
||||||
|
def validate_cidr(cls, v: list[str]) -> list[str]:
|
||||||
|
for r in v:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(r, strict=False)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -49,6 +61,10 @@ async def stop_scan(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
|
try:
|
||||||
|
uuid.UUID(run_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if not run:
|
if not run:
|
||||||
raise HTTPException(status_code=404, detail="Scan run not found")
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
||||||
@@ -64,6 +80,19 @@ async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pending", response_model=dict)
|
||||||
|
async def clear_pending(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, int]:
|
||||||
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||||
|
devices = result.scalars().all()
|
||||||
|
for device in devices:
|
||||||
|
await db.delete(device)
|
||||||
|
await db.commit()
|
||||||
|
return {"deleted": len(devices)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||||
@@ -92,7 +121,8 @@ async def hide_device(
|
|||||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if device:
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
device.status = "hidden"
|
device.status = "hidden"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"hidden": True}
|
return {"hidden": True}
|
||||||
@@ -103,7 +133,8 @@ async def ignore_device(
|
|||||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if device:
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
await db.delete(device)
|
await db.delete(device)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"ignored": True}
|
return {"ignored": True}
|
||||||
@@ -127,4 +158,5 @@ async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_u
|
|||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
logger.error("Failed to save scan config: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""APScheduler setup for background scan and status check jobs."""
|
"""APScheduler setup for background scan and status check jobs."""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -15,48 +16,87 @@ logger = logging.getLogger(__name__)
|
|||||||
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
async def _run_status_checks() -> None:
|
async def _check_single_node(
|
||||||
"""Check all nodes and broadcast results via WebSocket."""
|
node_id: str,
|
||||||
|
check_method: str,
|
||||||
|
check_target: str | None,
|
||||||
|
ip: str | None,
|
||||||
|
) -> tuple[str, dict[str, object] | None]:
|
||||||
|
"""Run a single node check; returns (node_id, result_or_None).
|
||||||
|
|
||||||
|
Accepts plain scalars — not an ORM object — so there is no risk of
|
||||||
|
DetachedInstanceError when the originating session has already closed.
|
||||||
|
"""
|
||||||
from app.api.routes.status import broadcast_status # avoid circular import
|
from app.api.routes.status import broadcast_status # avoid circular import
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
result = await db.execute(select(Node))
|
|
||||||
nodes = result.scalars().all()
|
|
||||||
|
|
||||||
for node in nodes:
|
|
||||||
if not node.check_method:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
check_result = await check_node(node.check_method, node.check_target, node.ip)
|
check_result = await check_node(check_method, check_target, ip)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
n = await db.get(Node, node.id)
|
n = await db.get(Node, node_id)
|
||||||
if n:
|
if n:
|
||||||
n.status = check_result["status"]
|
n.status = check_result["status"]
|
||||||
n.response_time_ms = check_result["response_time_ms"]
|
n.response_time_ms = check_result["response_time_ms"]
|
||||||
n.last_seen = datetime.now(timezone.utc) if check_result["status"] == "online" else n.last_seen
|
if check_result["status"] == "online":
|
||||||
|
n.last_seen = now
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await broadcast_status(
|
await broadcast_status(
|
||||||
node_id=node.id,
|
node_id=node_id,
|
||||||
status=check_result["status"],
|
status=check_result["status"],
|
||||||
checked_at=datetime.now(timezone.utc).isoformat(),
|
checked_at=now.isoformat(),
|
||||||
response_time_ms=check_result["response_time_ms"],
|
response_time_ms=check_result["response_time_ms"],
|
||||||
)
|
)
|
||||||
|
return node_id, check_result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Status check failed for node %s: %s", node.id, exc)
|
logger.error("Status check failed for node %s: %s", node_id, exc)
|
||||||
|
return node_id, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_status_checks() -> None:
|
||||||
|
"""Check all nodes concurrently and broadcast results via WebSocket."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Node))
|
||||||
|
nodes = result.scalars().all()
|
||||||
|
# Extract scalars while the session is open to avoid DetachedInstanceError
|
||||||
|
checkable = [
|
||||||
|
(n.id, n.check_method, n.check_target, n.ip)
|
||||||
|
for n in nodes
|
||||||
|
if n.check_method
|
||||||
|
]
|
||||||
|
|
||||||
|
if not checkable:
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.gather(*[
|
||||||
|
_check_single_node(node_id, method, target, ip)
|
||||||
|
for node_id, method, target, ip in checkable
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
|
try:
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to shut down previous scheduler instance: %s", exc)
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
|
scheduler.add_job(
|
||||||
|
_run_status_checks,
|
||||||
|
"interval",
|
||||||
|
seconds=settings.status_checker_interval,
|
||||||
|
id="status_checks",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
|
|
||||||
def reschedule_status_checks(interval_seconds: int) -> None:
|
def reschedule_status_checks(interval_seconds: int) -> None:
|
||||||
"""Update the status check interval on the running scheduler."""
|
"""Update the status check interval on the running scheduler."""
|
||||||
|
if interval_seconds < 10:
|
||||||
|
raise ValueError(f"interval_seconds must be >= 10, got {interval_seconds}")
|
||||||
if not scheduler.running:
|
if not scheduler.running:
|
||||||
logger.warning("Scheduler not running, skipping reschedule")
|
logger.warning("Scheduler not running, skipping reschedule")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
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")
|
||||||
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class Node(Base):
|
|||||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
height: 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)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
@@ -90,6 +91,7 @@ class PendingDevice(Base):
|
|||||||
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||||
status: Mapped[str] = mapped_column(String, default="pending")
|
status: Mapped[str] = mapped_column(String, default="pending")
|
||||||
|
discovery_source: Mapped[str | None] = mapped_column(String)
|
||||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -14,6 +16,16 @@ from app.db.database import init_db
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
# Ensure app logs are visible: attach a handler to the root logger if none
|
||||||
|
# exists (uvicorn only installs handlers on its own loggers, not the root).
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
if not root_logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
logging.getLogger("app").setLevel(logging.INFO)
|
||||||
|
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
|
||||||
await init_db()
|
await init_db()
|
||||||
settings.load_overrides()
|
settings.load_overrides()
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
@@ -23,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.4.0",
|
version="1.8.3",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class NodeSave(BaseModel):
|
|||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
|
bottom_handles: int = 1
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class NodeBase(BaseModel):
|
|||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
|
bottom_handles: int = 1
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -60,6 +61,7 @@ class NodeUpdate(BaseModel):
|
|||||||
show_hardware: bool | None = None
|
show_hardware: bool | None = None
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
|
bottom_handles: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
services: list[Any]
|
services: list[Any]
|
||||||
suggested_type: str | None
|
suggested_type: str | None
|
||||||
status: str
|
status: str
|
||||||
|
discovery_source: str | None
|
||||||
discovered_at: datetime
|
discovered_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -65,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated)
|
# Known OUI prefixes — lowercase, colon-separated, first 3 octets
|
||||||
_MAC_OUI_TYPES: dict[str, str] = {
|
_MAC_OUI_TYPES: dict[str, str] = {
|
||||||
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs)
|
# Hypervisors / VMs
|
||||||
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+)
|
"52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
|
||||||
|
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
|
||||||
"00:50:56": "vm", # VMware
|
"00:50:56": "vm", # VMware
|
||||||
"00:0c:29": "vm", # VMware Workstation / Fusion
|
"00:0c:29": "vm", # VMware Workstation / Fusion
|
||||||
"08:00:27": "vm", # VirtualBox
|
"08:00:27": "vm", # VirtualBox
|
||||||
"00:15:5d": "vm", # Hyper-V
|
"00:15:5d": "vm", # Hyper-V
|
||||||
|
# Shelly
|
||||||
|
"34:94:54": "iot",
|
||||||
|
"84:f3:eb": "iot",
|
||||||
|
"ec:fa:bc": "iot",
|
||||||
|
"30:c6:f7": "iot",
|
||||||
|
# Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
|
||||||
|
"a0:20:a6": "iot",
|
||||||
|
"24:62:ab": "iot",
|
||||||
|
"30:ae:a4": "iot",
|
||||||
|
"cc:50:e3": "iot",
|
||||||
|
"ac:67:b2": "iot",
|
||||||
|
"b4:e6:2d": "iot",
|
||||||
|
"3c:71:bf": "iot",
|
||||||
|
"8c:aa:b5": "iot",
|
||||||
|
# Sonoff / ITEAD
|
||||||
|
"dc:4f:22": "iot",
|
||||||
|
"e8:db:84": "iot",
|
||||||
|
# Tapo / TP-Link smart home
|
||||||
|
"b0:a7:b9": "iot",
|
||||||
|
"50:c7:bf": "iot",
|
||||||
|
"1c:3b:f3": "iot",
|
||||||
|
"10:27:f5": "iot",
|
||||||
|
# Philips Hue
|
||||||
|
"00:17:88": "iot",
|
||||||
|
"ec:b5:fa": "iot",
|
||||||
|
# IKEA Tradfri
|
||||||
|
"34:13:e8": "iot",
|
||||||
|
"00:21:2e": "iot",
|
||||||
|
# Tuya / Smart Life (widely used chip in many brands)
|
||||||
|
"d8:f1:5b": "iot",
|
||||||
|
"68:57:2d": "iot",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -101,10 +133,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
|||||||
37777: "camera", # Dahua
|
37777: "camera", # Dahua
|
||||||
34567: "camera", # Amcrest
|
34567: "camera", # Amcrest
|
||||||
2020: "camera", # Tapo
|
2020: "camera", # Tapo
|
||||||
# Smart-home / MQTT → iot
|
# Smart-home / MQTT / CoAP → iot
|
||||||
1883: "iot",
|
1883: "iot",
|
||||||
8883: "iot",
|
8883: "iot",
|
||||||
6052: "iot", # ESPHome
|
6052: "iot", # ESPHome dashboard
|
||||||
|
4915: "iot", # Shelly CoIoT
|
||||||
|
5683: "iot", # CoAP (Shelly Gen1, many IoT devices)
|
||||||
|
5684: "iot", # CoAP DTLS
|
||||||
# AP / wireless
|
# AP / wireless
|
||||||
8880: "ap", # UniFi HTTP
|
8880: "ap", # UniFi HTTP
|
||||||
8443: "ap", # UniFi HTTPS
|
8443: "ap", # UniFi HTTPS
|
||||||
@@ -115,8 +150,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
|||||||
|
|
||||||
|
|
||||||
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
|
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
|
||||||
"""Suggest a node type based on matched signatures and MAC OUI."""
|
"""Suggest a node type based on matched signatures, port hints, and MAC OUI."""
|
||||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"]
|
# IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override
|
||||||
|
mac_type = suggest_type_from_mac(mac)
|
||||||
|
if mac_type == "iot":
|
||||||
|
return "iot"
|
||||||
|
|
||||||
|
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
|
||||||
found: set[str] = set()
|
found: set[str] = set()
|
||||||
for p in open_ports:
|
for p in open_ports:
|
||||||
port = p["port"]
|
port = p["port"]
|
||||||
@@ -126,10 +166,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
|
|||||||
found.add(sig["suggested_node_type"])
|
found.add(sig["suggested_node_type"])
|
||||||
if port in _PORT_TYPE_HINTS:
|
if port in _PORT_TYPE_HINTS:
|
||||||
found.add(_PORT_TYPE_HINTS[port])
|
found.add(_PORT_TYPE_HINTS[port])
|
||||||
# MAC OUI is a lower-priority hint — only used if ports give no better answer
|
|
||||||
mac_type = suggest_type_from_mac(mac)
|
|
||||||
if mac_type:
|
if mac_type:
|
||||||
found.add(mac_type)
|
found.add(mac_type)
|
||||||
|
|
||||||
for t in priority:
|
for t in priority:
|
||||||
if t in found:
|
if t in found:
|
||||||
return t
|
return t
|
||||||
|
|||||||
+377
-93
@@ -1,10 +1,16 @@
|
|||||||
"""Network scanner: ARP sweep + nmap service detection."""
|
"""Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
@@ -12,18 +18,30 @@ from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Run IDs that have been requested to cancel
|
# Run IDs that have been requested to cancel (thread-safe via lock)
|
||||||
_cancelled_runs: set[str] = set()
|
_cancelled_runs: set[str] = set()
|
||||||
|
_cancelled_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Port list for service detection (Phase 2)
|
||||||
|
_EXTRA_PORTS = (
|
||||||
|
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
||||||
|
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
||||||
|
"3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984,"
|
||||||
|
"6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080,"
|
||||||
|
"8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443,"
|
||||||
|
"8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090,"
|
||||||
|
"9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051,"
|
||||||
|
"16686,34567,37777,51413,64738"
|
||||||
|
)
|
||||||
|
|
||||||
def request_cancel(run_id: str) -> None:
|
_MDNS_SERVICE_TYPES = [
|
||||||
"""Signal a running scan to stop early."""
|
"_http._tcp.local.",
|
||||||
_cancelled_runs.add(run_id)
|
"_shelly._tcp.local.",
|
||||||
|
"_esphomelib._tcp.local.",
|
||||||
|
"_hap._tcp.local.", # HomeKit Accessory Protocol
|
||||||
def _is_cancelled(run_id: str) -> bool:
|
"_mqtt._tcp.local.",
|
||||||
return run_id in _cancelled_runs
|
"_device-info._tcp.local.",
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import nmap
|
import nmap
|
||||||
@@ -32,50 +50,24 @@ except ImportError:
|
|||||||
_NMAP_AVAILABLE = False
|
_NMAP_AVAILABLE = False
|
||||||
logger.warning("python-nmap not available — scanner will run in mock mode")
|
logger.warning("python-nmap not available — scanner will run in mock mode")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zeroconf import ServiceStateChange
|
||||||
|
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||||
|
_ZEROCONF_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_ZEROCONF_AVAILABLE = False
|
||||||
|
logger.warning("zeroconf not available — mDNS discovery disabled")
|
||||||
|
|
||||||
def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
|
||||||
"""Run nmap -sV --open on target, return list of host dicts."""
|
|
||||||
if not _NMAP_AVAILABLE:
|
|
||||||
return _mock_scan(target)
|
|
||||||
|
|
||||||
nm = nmap.PortScanner()
|
def request_cancel(run_id: str) -> None:
|
||||||
try:
|
"""Signal a running scan to stop early."""
|
||||||
# Home lab port range: standard top-1000 + common self-hosted service ports
|
with _cancelled_lock:
|
||||||
extra_ports = (
|
_cancelled_runs.add(run_id)
|
||||||
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
|
||||||
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
|
||||||
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
|
|
||||||
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
|
|
||||||
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
|
|
||||||
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
|
|
||||||
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
|
|
||||||
)
|
|
||||||
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("nmap scan failed: %s", exc)
|
|
||||||
raise RuntimeError(str(exc)) from exc
|
|
||||||
|
|
||||||
hosts = []
|
|
||||||
for host in nm.all_hosts():
|
def _is_cancelled(run_id: str) -> bool:
|
||||||
if nm[host].state() != "up":
|
with _cancelled_lock:
|
||||||
continue
|
return run_id in _cancelled_runs
|
||||||
open_ports = []
|
|
||||||
for proto in nm[host].all_protocols():
|
|
||||||
for port, info in nm[host][proto].items():
|
|
||||||
if info["state"] == "open":
|
|
||||||
open_ports.append({
|
|
||||||
"port": port,
|
|
||||||
"protocol": proto,
|
|
||||||
"banner": info.get("product", "") + " " + info.get("version", ""),
|
|
||||||
})
|
|
||||||
hosts.append({
|
|
||||||
"ip": host,
|
|
||||||
"hostname": _resolve_hostname(host),
|
|
||||||
"mac": nm[host].get("addresses", {}).get("mac"),
|
|
||||||
"os": _extract_os(nm, host),
|
|
||||||
"open_ports": open_ports,
|
|
||||||
})
|
|
||||||
return hosts
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_hostname(ip: str) -> str | None:
|
def _resolve_hostname(ip: str) -> str | None:
|
||||||
@@ -95,6 +87,278 @@ def _extract_os(nm: object, host: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Read the OS ARP cache for recently-seen hosts in the target network.
|
||||||
|
Works without root on both Linux (/proc/net/arp) and macOS (arp -a).
|
||||||
|
Supplements nmap discovery — catches IoT and devices with all ports filtered.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(network, strict=False)
|
||||||
|
found: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# Linux: parse /proc/net/arp — present on any Linux kernel (including Docker)
|
||||||
|
proc_arp = "/proc/net/arp"
|
||||||
|
try:
|
||||||
|
with open(proc_arp) as f:
|
||||||
|
for line in f.readlines()[1:]: # skip header row
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
ip, mac = parts[0], parts[3]
|
||||||
|
if mac == "00:00:00:00:00:00":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(ip) in net:
|
||||||
|
found[ip] = {
|
||||||
|
"ip": ip, "mac": mac,
|
||||||
|
"hostname": _resolve_hostname(ip),
|
||||||
|
"os": None, "open_ports": [],
|
||||||
|
}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# /proc/net/arp opened successfully — return whatever we found (may be empty)
|
||||||
|
# Don't fall through to `arp -a` since we're on Linux
|
||||||
|
return found
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # Not Linux — fall through to macOS `arp -a`
|
||||||
|
|
||||||
|
# macOS: parse `arp -a` output
|
||||||
|
result = subprocess.run(["arp", "-a"], capture_output=True, text=True, timeout=5)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
ip, mac = m.group(1), m.group(2)
|
||||||
|
if mac in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(ip) in net:
|
||||||
|
found[ip] = {"ip": ip, "mac": mac, "hostname": _resolve_hostname(ip), "os": None, "open_ports": []}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return found
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[Phase 1] ARP cache lookup failed: %s", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Phase 1: Concurrent ICMP ping sweep + ARP cache.
|
||||||
|
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
|
||||||
|
Supplements with the OS ARP cache to catch devices that block ICMP.
|
||||||
|
Works in Docker with CAP_NET_RAW — no nmap, no false positives.
|
||||||
|
"""
|
||||||
|
net = ipaddress.ip_network(target, strict=False)
|
||||||
|
all_ips = [str(ip) for ip in net.hosts()]
|
||||||
|
logger.info("[Phase 1] Pinging %d hosts in %s ...", len(all_ips), target)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(50)
|
||||||
|
|
||||||
|
async def _ping(ip: str) -> str | None:
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ping", "-c", "1", "-W", "1", ip,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
return ip if proc.returncode == 0 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ping_results = await asyncio.gather(*[_ping(ip) for ip in all_ips])
|
||||||
|
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
|
||||||
|
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
|
||||||
|
|
||||||
|
# ARP cache: catch devices that block ICMP but were recently active,
|
||||||
|
# and enrich ping-alive hosts with their MAC addresses.
|
||||||
|
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
|
||||||
|
|
||||||
|
alive: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for ip in alive_ips:
|
||||||
|
mac = arp_cache.get(ip, {}).get("mac")
|
||||||
|
hostname = await asyncio.to_thread(_resolve_hostname, ip)
|
||||||
|
logger.info("[Phase 1] %s mac=%s hostname=%s (ping)", ip, mac or "n/a", hostname or "n/a")
|
||||||
|
alive[ip] = {"ip": ip, "mac": mac, "hostname": hostname, "os": None, "open_ports": []}
|
||||||
|
|
||||||
|
for ip, host in arp_cache.items():
|
||||||
|
if ip not in alive:
|
||||||
|
logger.info(
|
||||||
|
"[Phase 1] %s mac=%s hostname=%s (ARP cache only)",
|
||||||
|
ip, host.get("mac") or "n/a", host.get("hostname") or "n/a",
|
||||||
|
)
|
||||||
|
alive[ip] = host
|
||||||
|
|
||||||
|
return alive
|
||||||
|
|
||||||
|
|
||||||
|
def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Phase 2 — single-IP port scan with service detection.
|
||||||
|
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
||||||
|
"""
|
||||||
|
ip = host_dict["ip"]
|
||||||
|
logger.info("[Phase 2] Scanning %s ...", ip)
|
||||||
|
|
||||||
|
if not _NMAP_AVAILABLE:
|
||||||
|
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
is_root = os.geteuid() == 0
|
||||||
|
if is_root:
|
||||||
|
# SYN scan + version detection (fastest, most accurate)
|
||||||
|
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
||||||
|
else:
|
||||||
|
# TCP connect scan (-sT) — no raw sockets needed, works without root.
|
||||||
|
# nmap auto-selects -sT without root but being explicit avoids edge cases.
|
||||||
|
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
||||||
|
|
||||||
|
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
||||||
|
nm = nmap.PortScanner()
|
||||||
|
try:
|
||||||
|
nm.scan(hosts=ip, arguments=scan_args)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
all_scanned = nm.all_hosts()
|
||||||
|
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
|
||||||
|
if ip not in all_scanned:
|
||||||
|
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
open_ports = []
|
||||||
|
for proto in nm[ip].all_protocols():
|
||||||
|
for port, info in nm[ip][proto].items():
|
||||||
|
if info["state"] == "open":
|
||||||
|
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
|
||||||
|
open_ports.append({"port": port, "protocol": proto, "banner": banner})
|
||||||
|
|
||||||
|
if open_ports:
|
||||||
|
port_summary = ", ".join(
|
||||||
|
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
|
||||||
|
)
|
||||||
|
logger.info("[Phase 2] %s — %d open port(s): %s", ip, len(open_ports), port_summary)
|
||||||
|
else:
|
||||||
|
logger.info("[Phase 2] %s — 0 open ports detected", ip)
|
||||||
|
|
||||||
|
host_dict["open_ports"] = open_ports
|
||||||
|
if not host_dict["mac"]:
|
||||||
|
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
|
||||||
|
host_dict["os"] = _extract_os(nm, ip)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
|
||||||
|
async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Phase 2: Per-IP service detection with bounded concurrency.
|
||||||
|
Each host is scanned independently in a thread — no inter-host timeout interference.
|
||||||
|
Up to 10 hosts scanned concurrently.
|
||||||
|
"""
|
||||||
|
if not alive:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info("[Phase 2] Starting per-IP port scan for %d host(s)", len(alive))
|
||||||
|
semaphore = asyncio.Semaphore(10)
|
||||||
|
|
||||||
|
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with semaphore:
|
||||||
|
return await asyncio.to_thread(_nmap_scan_single, host_dict)
|
||||||
|
|
||||||
|
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
|
||||||
|
results = []
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, BaseException):
|
||||||
|
logger.warning("[Phase 2] Unexpected error in gather: %s", item)
|
||||||
|
else:
|
||||||
|
results.append(item)
|
||||||
|
logger.info("[Phase 2] Completed — %d/%d host(s) scanned", len(results), len(alive))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Two-phase scan for a CIDR range.
|
||||||
|
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
|
||||||
|
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
|
||||||
|
"""
|
||||||
|
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
|
||||||
|
if not _NMAP_AVAILABLE:
|
||||||
|
logger.warning("[Scan] nmap not available — returning mock data")
|
||||||
|
return _mock_scan(target)
|
||||||
|
try:
|
||||||
|
alive = await _ping_sweep(target)
|
||||||
|
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
|
||||||
|
len(alive), target, ", ".join(sorted(alive.keys())))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Phase 1 ping sweep failed: %s", exc)
|
||||||
|
raise RuntimeError(str(exc)) from exc
|
||||||
|
return await _nmap_port_scan(alive)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Passive mDNS/Bonjour sweep.
|
||||||
|
Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc.
|
||||||
|
Runs for `timeout` seconds then returns what it found.
|
||||||
|
"""
|
||||||
|
if not _ZEROCONF_AVAILABLE:
|
||||||
|
return []
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
found_services: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def _on_change(
|
||||||
|
zeroconf: Any,
|
||||||
|
service_type: str,
|
||||||
|
name: str,
|
||||||
|
state_change: Any,
|
||||||
|
) -> None:
|
||||||
|
if state_change == ServiceStateChange.Added:
|
||||||
|
found_services.append((service_type, name))
|
||||||
|
|
||||||
|
discovered: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncZeroconf() as azc:
|
||||||
|
browser = AsyncServiceBrowser(
|
||||||
|
azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change]
|
||||||
|
)
|
||||||
|
await asyncio.sleep(timeout)
|
||||||
|
await browser.async_cancel()
|
||||||
|
|
||||||
|
for service_type, name in found_services:
|
||||||
|
try:
|
||||||
|
info = AsyncServiceInfo(service_type, name)
|
||||||
|
await info.async_request(azc.zeroconf, 3000)
|
||||||
|
if not info.addresses:
|
||||||
|
continue
|
||||||
|
ip = str(ipaddress.IPv4Address(info.addresses[0]))
|
||||||
|
if ip in discovered:
|
||||||
|
continue
|
||||||
|
discovered[ip] = {
|
||||||
|
"ip": ip,
|
||||||
|
"hostname": info.server,
|
||||||
|
"mac": None,
|
||||||
|
"os": None,
|
||||||
|
"open_ports": (
|
||||||
|
[{"port": info.port, "protocol": "tcp", "banner": ""}]
|
||||||
|
if info.port else []
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("mDNS resolution failed for %s: %s", name, exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("mDNS discovery error: %s", exc)
|
||||||
|
|
||||||
|
logger.info("mDNS discovery found %d device(s)", len(discovered))
|
||||||
|
return list(discovered.values())
|
||||||
|
|
||||||
|
|
||||||
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||||
"""Return fake results for dev/test environments without nmap."""
|
"""Return fake results for dev/test environments without nmap."""
|
||||||
return [
|
return [
|
||||||
@@ -113,63 +377,59 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||||
# Avoid circular import
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.status import broadcast_scan_update
|
from app.api.routes.status import broadcast_scan_update
|
||||||
|
|
||||||
devices_found = 0
|
devices_found = 0
|
||||||
|
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
|
||||||
try:
|
try:
|
||||||
# Clean up stale pending devices whose IPs are already in the canvas
|
# Validate all ranges are valid CIDRs before passing anything to nmap
|
||||||
# (covers devices approved between scans, or pre-existing canvas nodes)
|
for r in ranges:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(r, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid CIDR range: {r!r}") from None
|
||||||
|
|
||||||
|
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
|
||||||
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
|
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
|
||||||
canvas_ips = {row[0] for row in canvas_ips_result.fetchall()}
|
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
|
||||||
|
|
||||||
|
hidden_ips_result = await db.execute(
|
||||||
|
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
||||||
|
)
|
||||||
|
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
||||||
|
|
||||||
|
# Clean up stale pending devices whose IPs are already in the canvas
|
||||||
if canvas_ips:
|
if canvas_ips:
|
||||||
stale_result = await db.execute(
|
from sqlalchemy import delete as sa_delete
|
||||||
select(PendingDevice).where(
|
await db.execute(
|
||||||
|
sa_delete(PendingDevice).where(
|
||||||
PendingDevice.status == "pending",
|
PendingDevice.status == "pending",
|
||||||
PendingDevice.ip.in_(canvas_ips),
|
PendingDevice.ip.in_(canvas_ips),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for stale in stale_result.scalars().all():
|
|
||||||
await db.delete(stale)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
for cidr in ranges:
|
# Start mDNS discovery in the background while nmap scans run
|
||||||
if _is_cancelled(run_id):
|
mdns_task = asyncio.create_task(_mdns_discover())
|
||||||
break
|
|
||||||
|
|
||||||
# Run nmap in a thread pool — does not block the event loop
|
# Track IPs found by nmap so mDNS doesn't duplicate them
|
||||||
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
nmap_ips: set[str] = set()
|
||||||
|
|
||||||
for host in hosts:
|
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
|
||||||
if _is_cancelled(run_id):
|
nonlocal devices_found
|
||||||
break
|
|
||||||
ip = host["ip"]
|
ip = host["ip"]
|
||||||
|
|
||||||
# Skip if device is already in the canvas (approved node)
|
# Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
|
||||||
canvas_result = await db.execute(
|
if ip in canvas_ips:
|
||||||
select(Node).where(Node.ip == ip)
|
|
||||||
)
|
|
||||||
if canvas_result.scalar_one_or_none() is not None:
|
|
||||||
logger.debug("Skipping %s — already in canvas", ip)
|
logger.debug("Skipping %s — already in canvas", ip)
|
||||||
continue
|
return
|
||||||
|
if ip in hidden_ips:
|
||||||
# Skip if device was explicitly hidden by the user
|
|
||||||
hidden_result = await db.execute(
|
|
||||||
select(PendingDevice).where(
|
|
||||||
PendingDevice.ip == ip,
|
|
||||||
PendingDevice.status == "hidden",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if hidden_result.scalar_one_or_none() is not None:
|
|
||||||
logger.debug("Skipping %s — hidden by user", ip)
|
logger.debug("Skipping %s — hidden by user", ip)
|
||||||
continue
|
return
|
||||||
|
|
||||||
services = fingerprint_ports(host["open_ports"])
|
services = fingerprint_ports(host["open_ports"])
|
||||||
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
||||||
|
|
||||||
# Update existing pending device or create a new one
|
|
||||||
existing_result = await db.execute(
|
existing_result = await db.execute(
|
||||||
select(PendingDevice).where(
|
select(PendingDevice).where(
|
||||||
PendingDevice.ip == ip,
|
PendingDevice.ip == ip,
|
||||||
@@ -184,7 +444,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
existing.services = services
|
existing.services = services
|
||||||
existing.suggested_type = suggested_type
|
existing.suggested_type = suggested_type
|
||||||
else:
|
else:
|
||||||
device = PendingDevice(
|
db.add(PendingDevice(
|
||||||
ip=ip,
|
ip=ip,
|
||||||
mac=host.get("mac"),
|
mac=host.get("mac"),
|
||||||
hostname=host.get("hostname"),
|
hostname=host.get("hostname"),
|
||||||
@@ -192,21 +452,42 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
services=services,
|
services=services,
|
||||||
suggested_type=suggested_type,
|
suggested_type=suggested_type,
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
discovery_source=discovery_source,
|
||||||
db.add(device)
|
))
|
||||||
devices_found += 1
|
devices_found += 1
|
||||||
|
|
||||||
# Commit immediately so the device is visible right away
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||||
|
|
||||||
# Update running count on the scan run record
|
# nmap scan per CIDR — results stream in progressively
|
||||||
|
for cidr in ranges:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
|
hosts = await _nmap_scan(cidr)
|
||||||
|
for host in hosts:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
|
nmap_ips.add(host["ip"])
|
||||||
|
await _process_host(host)
|
||||||
|
|
||||||
|
# Update ScanRun count once after all CIDR ranges
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.devices_found = devices_found
|
run.devices_found = devices_found
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Push WS event so the frontend refreshes pending panel
|
# Collect mDNS results — task already has its own 4s internal timeout
|
||||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
if not _is_cancelled(run_id):
|
||||||
|
mdns_hosts = await mdns_task
|
||||||
|
|
||||||
|
for host in mdns_hosts:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
|
if host["ip"] in nmap_ips:
|
||||||
|
continue # already processed with richer nmap data
|
||||||
|
await _process_host(host, discovery_source="mdns")
|
||||||
|
else:
|
||||||
|
mdns_task.cancel()
|
||||||
|
|
||||||
# Mark scan as done or cancelled
|
# Mark scan as done or cancelled
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
@@ -218,6 +499,8 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Scan failed: %s", exc)
|
logger.error("Scan failed: %s", exc)
|
||||||
|
if mdns_task is not None and not mdns_task.done():
|
||||||
|
mdns_task.cancel()
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.status = "error"
|
run.status = "error"
|
||||||
@@ -225,4 +508,5 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
finally:
|
finally:
|
||||||
|
with _cancelled_lock:
|
||||||
_cancelled_runs.discard(run_id)
|
_cancelled_runs.discard(run_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -57,8 +58,12 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||||
|
else:
|
||||||
|
args = ["ping", "-c", "1", "-W", "1", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ping", "-c", "1", "-W", "1", host,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
@@ -16,6 +16,7 @@ pyyaml==6.0.2
|
|||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
zeroconf==0.131.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
|
|||||||
@@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature():
|
|||||||
]):
|
]):
|
||||||
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
||||||
assert result == "camera"
|
assert result == "camera"
|
||||||
|
|
||||||
|
|
||||||
|
# ── IoT detection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_from_mqtt_port():
|
||||||
|
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_from_coap_port():
|
||||||
|
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_from_esphome_port():
|
||||||
|
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_shelly_mac_overrides_http_port():
|
||||||
|
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
|
||||||
|
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_espressif_mac_returns_iot():
|
||||||
|
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_tuya_mac_returns_iot():
|
||||||
|
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
|
||||||
|
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
|
||||||
|
result = suggest_node_type([
|
||||||
|
{"port": 80, "protocol": "tcp"},
|
||||||
|
{"port": 1883, "protocol": "tcp"},
|
||||||
|
])
|
||||||
|
assert result == "iot"
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ async def test_stop_scan_requires_auth(client: AsyncClient):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
||||||
res = await client.post("/api/v1/scan/nonexistent-id/stop", headers=headers)
|
import uuid as _uuid
|
||||||
|
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
|
||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_run_id() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db():
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_scan_run(run_id: str) -> ScanRun:
|
||||||
|
return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _ping_sweep
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_returns_alive_hosts():
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def fake_ping(ip: str) -> str | None:
|
||||||
|
return ip if ip in {"192.168.1.1", "192.168.1.2"} else None
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ping_sweep", wraps=None):
|
||||||
|
pass # just ensure import is fine
|
||||||
|
|
||||||
|
# Patch asyncio.create_subprocess_exec to simulate ping responses
|
||||||
|
responding = {"192.168.1.1", "192.168.1.2"}
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
ip = args[-1]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0 if ip in responding else 1
|
||||||
|
proc.wait = AsyncMock(return_value=proc.returncode)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value={}), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/30") # .1 .2 only in /30
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.2" in result
|
||||||
|
for host in result.values():
|
||||||
|
assert host["open_ports"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_excludes_non_responding():
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
ip = args[-1]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0 if ip == "192.168.1.1" else 1
|
||||||
|
proc.wait = AsyncMock(return_value=proc.returncode)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value={}), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/30")
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.2" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_supplements_with_arp_cache():
|
||||||
|
"""Devices that block ICMP but appear in ARP cache should still be discovered."""
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 1 # all pings fail
|
||||||
|
proc.wait = AsyncMock(return_value=1)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
arp_extra = {
|
||||||
|
"192.168.1.10": {"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:10", "hostname": None, "os": None, "open_ports": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert "192.168.1.10" in result
|
||||||
|
assert result["192.168.1.10"]["mac"] == "aa:bb:cc:dd:ee:10"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_enriches_mac_from_arp_cache():
|
||||||
|
"""Ping-alive hosts with no ARP entry get their MAC from the ARP cache."""
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
ip = args[-1]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0 if ip == "192.168.1.1" else 1
|
||||||
|
proc.wait = AsyncMock(return_value=proc.returncode)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
arp_extra = {
|
||||||
|
"192.168.1.1": {"ip": "192.168.1.1", "mac": "de:ad:be:ef:00:01", "hostname": None, "os": None, "open_ports": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/30")
|
||||||
|
|
||||||
|
assert result["192.168.1.1"]["mac"] == "de:ad:be:ef:00:01"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _arp_table_hosts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_arp_table_hosts_parses_proc_net_arp():
|
||||||
|
import io # noqa: PLC0415
|
||||||
|
|
||||||
|
from app.services.scanner import _arp_table_hosts
|
||||||
|
|
||||||
|
arp_content = (
|
||||||
|
"IP address HW type Flags HW address Mask Device\n"
|
||||||
|
"192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:01 * eth0\n"
|
||||||
|
"192.168.1.50 0x1 0x2 aa:bb:cc:dd:ee:02 * eth0\n"
|
||||||
|
"10.0.0.1 0x1 0x2 aa:bb:cc:dd:ee:03 * eth0\n" # outside subnet
|
||||||
|
"192.168.1.99 0x1 0x2 00:00:00:00:00:00 * eth0\n" # incomplete
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.__enter__ = MagicMock(return_value=io.StringIO(arp_content))
|
||||||
|
mock_file.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("builtins.open", return_value=mock_file), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = _arp_table_hosts("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.50" in result
|
||||||
|
assert "10.0.0.1" not in result # outside target subnet
|
||||||
|
assert "192.168.1.99" not in result # zero MAC skipped
|
||||||
|
|
||||||
|
|
||||||
|
def test_arp_table_hosts_parses_macos_arp_output():
|
||||||
|
from app.services.scanner import _arp_table_hosts
|
||||||
|
|
||||||
|
arp_output = (
|
||||||
|
"router.lan (192.168.1.1) at aa:bb:cc:dd:ee:01 on en0 ifscope [ethernet]\n"
|
||||||
|
"device.lan (192.168.1.20) at aa:bb:cc:dd:ee:02 on en0 ifscope [ethernet]\n"
|
||||||
|
"? (192.168.1.99) at (incomplete) on en0 ifscope [ethernet]\n"
|
||||||
|
"? (10.0.0.1) at aa:bb:cc:dd:ee:04 on en0 ifscope [ethernet]\n" # outside subnet
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = arp_output
|
||||||
|
|
||||||
|
with patch("builtins.open", side_effect=FileNotFoundError), \
|
||||||
|
patch("subprocess.run", return_value=mock_result), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = _arp_table_hosts("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.20" in result
|
||||||
|
assert "192.168.1.99" not in result # incomplete MAC
|
||||||
|
assert "10.0.0.1" not in result # outside subnet
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _nmap_scan_single (Phase 2 per-IP worker)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_nmap_scan_single_detects_open_ports():
|
||||||
|
from app.services.scanner import _nmap_scan_single
|
||||||
|
|
||||||
|
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||||
|
|
||||||
|
# Build a realistic host entry: protocols → ports → port info
|
||||||
|
port_info = {80: {"state": "open", "product": "nginx", "version": "1.24"}}
|
||||||
|
mock_host = MagicMock()
|
||||||
|
mock_host.all_protocols.return_value = ["tcp"]
|
||||||
|
mock_host.__getitem__ = MagicMock(return_value=port_info)
|
||||||
|
mock_host.get.return_value = {}
|
||||||
|
|
||||||
|
mock_nm = MagicMock()
|
||||||
|
mock_nm.all_hosts.return_value = ["192.168.1.10"]
|
||||||
|
mock_nm.__getitem__ = MagicMock(return_value=mock_host)
|
||||||
|
|
||||||
|
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
|
||||||
|
patch("app.services.scanner._extract_os", return_value=None):
|
||||||
|
result = _nmap_scan_single(host)
|
||||||
|
|
||||||
|
assert len(result["open_ports"]) == 1
|
||||||
|
assert result["open_ports"][0]["port"] == 80
|
||||||
|
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nmap_scan_single_returns_host_unchanged_on_error():
|
||||||
|
from app.services.scanner import _nmap_scan_single
|
||||||
|
|
||||||
|
host = {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||||
|
mock_nm = MagicMock()
|
||||||
|
mock_nm.scan.side_effect = Exception("nmap error")
|
||||||
|
|
||||||
|
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
|
||||||
|
result = _nmap_scan_single(host)
|
||||||
|
|
||||||
|
assert result["ip"] == "192.168.1.20"
|
||||||
|
assert result["open_ports"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nmap_scan_single_returns_host_unchanged_when_no_results():
|
||||||
|
"""Host confirmed alive in Phase 1 but all ports filtered — keep it with empty ports."""
|
||||||
|
from app.services.scanner import _nmap_scan_single
|
||||||
|
|
||||||
|
host = {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}
|
||||||
|
mock_nm = MagicMock()
|
||||||
|
mock_nm.all_hosts.return_value = [] # no results
|
||||||
|
|
||||||
|
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
|
||||||
|
result = _nmap_scan_single(host)
|
||||||
|
|
||||||
|
assert result["ip"] == "192.168.1.30"
|
||||||
|
assert result["open_ports"] == []
|
||||||
|
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _nmap_scan
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_scan_uses_mock_when_nmap_unavailable():
|
||||||
|
from app.services.scanner import _nmap_scan
|
||||||
|
|
||||||
|
with patch("app.services.scanner._NMAP_AVAILABLE", False):
|
||||||
|
result = await _nmap_scan("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["ip"] == "192.168.1.99"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_scan_raises_on_sweep_error():
|
||||||
|
from app.services.scanner import _nmap_scan
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ping_sweep", side_effect=Exception("ping sweep failed")), \
|
||||||
|
pytest.raises(RuntimeError, match="ping sweep failed"):
|
||||||
|
await _nmap_scan("192.168.1.0/24")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _mdns_discover
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mdns_discover_returns_empty_when_zeroconf_unavailable():
|
||||||
|
from app.services.scanner import _mdns_discover
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ZEROCONF_AVAILABLE", False):
|
||||||
|
result = await _mdns_discover()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mdns_discover_returns_devices():
|
||||||
|
from app.services.scanner import _mdns_discover
|
||||||
|
|
||||||
|
mock_info = MagicMock()
|
||||||
|
mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80
|
||||||
|
mock_info.server = "shelly1.local."
|
||||||
|
mock_info.port = 80
|
||||||
|
mock_info.async_request = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
mock_browser = AsyncMock()
|
||||||
|
mock_browser.async_cancel = AsyncMock()
|
||||||
|
|
||||||
|
# Simulate a service being found during the sleep
|
||||||
|
captured_handler: list = []
|
||||||
|
|
||||||
|
def fake_browser(zc, types, handlers):
|
||||||
|
captured_handler.extend(handlers)
|
||||||
|
return mock_browser
|
||||||
|
|
||||||
|
from zeroconf import ServiceStateChange
|
||||||
|
|
||||||
|
async def fake_sleep(t):
|
||||||
|
# Fire the handler as if a device was discovered
|
||||||
|
for h in captured_handler:
|
||||||
|
h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added)
|
||||||
|
|
||||||
|
mock_azc = AsyncMock()
|
||||||
|
mock_azc.__aenter__ = AsyncMock(return_value=mock_azc)
|
||||||
|
mock_azc.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_azc.zeroconf = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \
|
||||||
|
patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \
|
||||||
|
patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \
|
||||||
|
patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \
|
||||||
|
patch("asyncio.sleep", side_effect=fake_sleep):
|
||||||
|
result = await _mdns_discover(timeout=0.01)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["ip"] == "192.168.1.80"
|
||||||
|
assert result[0]["hostname"] == "shelly1.local."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _nmap_port_scan (Phase 2 concurrency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_port_scan_returns_empty_when_no_alive_hosts():
|
||||||
|
from app.services.scanner import _nmap_port_scan
|
||||||
|
|
||||||
|
result = await _nmap_port_scan({})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_port_scan_tolerates_single_host_exception():
|
||||||
|
"""A single per-host failure should not abort the entire Phase 2 gather."""
|
||||||
|
from app.services.scanner import _nmap_port_scan
|
||||||
|
|
||||||
|
hosts = {
|
||||||
|
"192.168.1.1": {"ip": "192.168.1.1", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
||||||
|
"192.168.1.2": {"ip": "192.168.1.2", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def _flaky_scan(host_dict):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if host_dict["ip"] == "192.168.1.1":
|
||||||
|
raise RuntimeError("simulated nmap crash")
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
with patch("app.services.scanner._nmap_scan_single", side_effect=_flaky_scan), \
|
||||||
|
patch("app.services.scanner._NMAP_AVAILABLE", True):
|
||||||
|
result = await _nmap_port_scan(hosts)
|
||||||
|
|
||||||
|
assert call_count == 2
|
||||||
|
# The crashing host is dropped; the healthy one survives
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["ip"] == "192.168.1.2"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_scan integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice))
|
||||||
|
devices = result.scalars().all()
|
||||||
|
|
||||||
|
assert any(d.ip == "192.168.1.5" for d in devices)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_mdns_only_device_added(mem_db):
|
||||||
|
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=[]), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=mdns_hosts), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
|
||||||
|
device = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assert device is not None
|
||||||
|
assert device.status == "pending"
|
||||||
|
assert device.discovery_source == "mdns"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||||
|
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
|
||||||
|
devices = result.scalars().all()
|
||||||
|
|
||||||
|
assert len(devices) == 1 # not duplicated
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_skips_canvas_nodes(mem_db):
|
||||||
|
"""Hosts already approved onto the canvas must be skipped."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
canvas_node = Node(
|
||||||
|
id=str(uuid.uuid4()), label="PVE", type="proxmox",
|
||||||
|
ip="192.168.1.100", status="online",
|
||||||
|
)
|
||||||
|
session.add(canvas_node)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_skips_hidden_devices(mem_db):
|
||||||
|
"""Hosts hidden by the user must not re-appear in pending."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
|
||||||
|
session.add(hidden)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.55", "hostname": None, "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
|
||||||
|
"""Cancelling a running scan sets the ScanRun status to 'cancelled'."""
|
||||||
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
request_cancel(run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=[]), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = await session.get(ScanRun, run_id)
|
||||||
|
assert run is not None
|
||||||
|
assert run.status == "cancelled"
|
||||||
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import _tcp_connect, check_node
|
from app.services.status_checker import _ping, _tcp_connect, check_node
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -149,6 +149,48 @@ async def test_check_node_exception_returns_offline():
|
|||||||
assert result["response_time_ms"] is None
|
assert result["response_time_ms"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- _ping platform args ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_unix_args_on_non_windows():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-c" in captured["args"]
|
||||||
|
assert "-W" in captured["args"]
|
||||||
|
assert "-n" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_windows_args_on_win32():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-n" in captured["args"]
|
||||||
|
assert "-w" in captured["args"]
|
||||||
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
# --- _tcp_connect ---
|
# --- _tcp_connect ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Generated
+793
-878
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.0",
|
"version": "1.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^1.7.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
|
|||||||
+39
-5
@@ -44,6 +44,8 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
||||||
|
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
@@ -258,13 +260,16 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
if (oldEdge) deleteEdge(oldEdge.id)
|
if (oldEdge) deleteEdge(oldEdge.id)
|
||||||
}
|
}
|
||||||
// Create new virtual edge: LXC top → Proxmox bottom
|
// Create virtual edge only when parent is NOT in container mode
|
||||||
|
// (container mode shows containment visually — no edge needed)
|
||||||
if (newParentId) {
|
if (newParentId) {
|
||||||
// Pass type as extra field — canvasStore.onConnect casts to Connection & Partial<EdgeData>
|
const parentNode = nodes.find((n) => n.id === newParentId)
|
||||||
|
if (!parentNode?.data.container_mode) {
|
||||||
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
|
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setEditNodeId(null)
|
setEditNodeId(null)
|
||||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
||||||
|
|
||||||
@@ -367,6 +372,8 @@ export default function App() {
|
|||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
onNodeApproved={setEditNodeId}
|
||||||
|
forceView={sidebarForceView}
|
||||||
|
highlightPendingId={highlightPendingId}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -383,7 +390,19 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
<CanvasContainer
|
||||||
|
onConnect={handleEdgeConnect}
|
||||||
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDragStart={snapshotHistory}
|
||||||
|
onOpenPending={(deviceId) => {
|
||||||
|
setHighlightPendingId(undefined)
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => {
|
||||||
|
setHighlightPendingId(deviceId)
|
||||||
|
setSidebarForceView('pending')
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +454,11 @@ export default function App() {
|
|||||||
<ScanConfigModal
|
<ScanConfigModal
|
||||||
open={scanConfigOpen}
|
open={scanConfigOpen}
|
||||||
onClose={() => setScanConfigOpen(false)}
|
onClose={() => setScanConfigOpen(false)}
|
||||||
onScanNow={() => toast.success('Scan triggered')}
|
onScanNow={() => {
|
||||||
|
toast.success('Network scan started — check Scan History for results')
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => setSidebarForceView('history'), 0)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -481,7 +504,18 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
<SearchModal
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
onOpenPending={(deviceId) => {
|
||||||
|
setHighlightPendingId(undefined)
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => {
|
||||||
|
setHighlightPendingId(deviceId)
|
||||||
|
setSidebarForceView('pending')
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const scanApi = {
|
|||||||
pending: () => api.get('/scan/pending'),
|
pending: () => api.get('/scan/pending'),
|
||||||
hidden: () => api.get('/scan/hidden'),
|
hidden: () => api.get('/scan/hidden'),
|
||||||
runs: () => api.get('/scan/runs'),
|
runs: () => api.get('/scan/runs'),
|
||||||
|
clearPending: () => api.delete('/scan/pending'),
|
||||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
Controls,
|
Controls,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
|
useReactFlow,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
@@ -36,7 +37,8 @@ const STORAGE_KEY = 'homelable_canvas'
|
|||||||
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
||||||
|
|
||||||
function LiveViewCanvas() {
|
function LiveViewCanvas() {
|
||||||
const { nodes, edges, loadCanvas } = useCanvasStore()
|
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||||
|
const { fitView } = useReactFlow()
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||||
@@ -87,6 +89,15 @@ function LiveViewCanvas() {
|
|||||||
})
|
})
|
||||||
}, [loadCanvas])
|
}, [loadCanvas])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
fitView({ padding: 0.12, duration: 350 })
|
||||||
|
clearFitViewPending()
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
|
||||||
|
|
||||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
const ip = node.data.ip
|
const ip = node.data.ip
|
||||||
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
||||||
@@ -129,7 +140,6 @@ function LiveViewCanvas() {
|
|||||||
elementsSelectable={false}
|
elementsSelectable={false}
|
||||||
panOnDrag
|
panOnDrag
|
||||||
zoomOnScroll
|
zoomOnScroll
|
||||||
fitView
|
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => { setSearch('') })
|
||||||
|
|
||||||
// ── No key ────────────────────────────────────────────────────────────────
|
// ── No key ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows no-key error when ?key= is missing', async () => {
|
it('shows no-key error when ?key= is missing', async () => {
|
||||||
@@ -133,11 +136,25 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
|
|
||||||
// ── Standalone mode ────────────────────────────────────────────────────────
|
// ── Standalone mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const XYFLOW_MOCK = {
|
||||||
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
ReactFlow: () => <div data-testid="react-flow" />,
|
||||||
|
Background: () => null,
|
||||||
|
Controls: () => null,
|
||||||
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
|
}
|
||||||
|
|
||||||
describe('LiveView (standalone — localStorage)', () => {
|
describe('LiveView (standalone — localStorage)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||||
vi.mocked(liveviewApi.load).mockReset()
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setSearch('')
|
||||||
|
vi.unstubAllEnvs()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads canvas from localStorage without calling the API', async () => {
|
it('loads canvas from localStorage without calling the API', async () => {
|
||||||
@@ -151,25 +168,12 @@ describe('LiveView (standalone — localStorage)', () => {
|
|||||||
}
|
}
|
||||||
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
|
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
|
||||||
|
|
||||||
// Stub VITE_STANDALONE before re-importing
|
|
||||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
|
||||||
vi.resetModules()
|
|
||||||
const { default: LiveViewStandalone } = await import('../LiveView')
|
|
||||||
|
|
||||||
setSearch('') // no key needed in standalone
|
|
||||||
render(<LiveViewStandalone />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
|
||||||
})
|
|
||||||
expect(liveviewApi.load).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
vi.unstubAllEnvs()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows canvas (empty) when localStorage has no saved data', async () => {
|
|
||||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
|
const mockLoad = vi.fn()
|
||||||
|
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||||
|
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||||
const { default: LiveViewStandalone } = await import('../LiveView')
|
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||||
|
|
||||||
setSearch('')
|
setSearch('')
|
||||||
@@ -178,8 +182,24 @@ describe('LiveView (standalone — localStorage)', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
})
|
})
|
||||||
expect(liveviewApi.load).not.toHaveBeenCalled()
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
vi.unstubAllEnvs()
|
it('shows canvas (empty) when localStorage has no saved data', async () => {
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const mockLoad = vi.fn()
|
||||||
|
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||||
|
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||||
|
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||||
|
|
||||||
|
setSearch('')
|
||||||
|
render(<LiveViewStandalone />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
SelectionMode,
|
SelectionMode,
|
||||||
|
useReactFlow,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Connection,
|
type Connection,
|
||||||
@@ -25,15 +26,28 @@ interface CanvasContainerProps {
|
|||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
setSelectedNode, snapshotHistory,
|
setSelectedNode, snapshotHistory,
|
||||||
|
fitViewPending, clearFitViewPending,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
fitView({ padding: 0.12, duration: 350 })
|
||||||
|
clearFitViewPending()
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
@@ -76,8 +90,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
multiSelectionKeyCode={['Meta', 'Control']}
|
multiSelectionKeyCode={['Meta', 'Control']}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[16, 16]}
|
snapGrid={[8, 8]}
|
||||||
fitView
|
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
elevateNodesOnSelect={false}
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
@@ -89,7 +102,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
size={1}
|
size={1}
|
||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<SearchBar />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
<Controls>
|
<Controls>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onClick={() => setLassoMode((m) => !m)}
|
onClick={() => setLassoMode((m) => !m)}
|
||||||
|
|||||||
@@ -2,15 +2,27 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { useReactFlow } from '@xyflow/react'
|
import { useReactFlow } from '@xyflow/react'
|
||||||
import { Search, X } from 'lucide-react'
|
import { Search, X } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
import { NODE_TYPE_LABELS } from '@/types'
|
import { NODE_TYPE_LABELS } from '@/types'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
export function SearchBar() {
|
interface SearchBarProps {
|
||||||
|
onOpenPending?: (deviceId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const { nodes, setSelectedNode } = useCanvasStore()
|
const { nodes, setSelectedNode } = useCanvasStore()
|
||||||
const { setCenter } = useReactFlow()
|
const { setCenter } = useReactFlow()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
@@ -31,7 +43,7 @@ export function SearchBar() {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const q = query.toLowerCase().trim()
|
const q = query.toLowerCase().trim()
|
||||||
const results = q
|
const nodeResults = q
|
||||||
? nodes.filter((n) => {
|
? nodes.filter((n) => {
|
||||||
if (n.data.type === 'groupRect') return false
|
if (n.data.type === 'groupRect') return false
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +55,19 @@ export function SearchBar() {
|
|||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const pendingResults = q
|
||||||
|
? pendingDevices.filter((d) =>
|
||||||
|
d.ip.toLowerCase().includes(q) ||
|
||||||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.services.some((s) =>
|
||||||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
|
s.category?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
).slice(0, 4)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const totalResults = nodeResults.length + pendingResults.length
|
||||||
|
|
||||||
const goToNode = (id: string) => {
|
const goToNode = (id: string) => {
|
||||||
const node = nodes.find((n) => n.id === id)
|
const node = nodes.find((n) => n.id === id)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
@@ -101,7 +126,7 @@ export function SearchBar() {
|
|||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
|
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
|
||||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
{totalResults} result{totalResults !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -113,9 +138,9 @@ export function SearchBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 && (
|
{totalResults > 0 && (
|
||||||
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||||
{results.map((n) => (
|
{nodeResults.map((n) => (
|
||||||
<button
|
<button
|
||||||
key={n.id}
|
key={n.id}
|
||||||
onClick={() => goToNode(n.id)}
|
onClick={() => goToNode(n.id)}
|
||||||
@@ -146,10 +171,43 @@ export function SearchBar() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||||
|
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
|
||||||
|
)}
|
||||||
|
{pendingResults.map((d) => {
|
||||||
|
const serviceName = d.services.find((s) => s.service_name)?.service_name
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '7px 12px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{d.hostname ?? d.ip}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||||
|
{serviceName ?? d.ip}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q && results.length === 0 && (
|
{q && totalResults === 0 && (
|
||||||
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
|
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
|
||||||
No results for “{query}”
|
No results for “{query}”
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
SelectionMode: { Partial: 'partial' },
|
SelectionMode: { Partial: 'partial' },
|
||||||
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -141,9 +142,9 @@ describe('CanvasContainer', () => {
|
|||||||
expect(rfProps.snapToGrid).toBe(true)
|
expect(rfProps.snapToGrid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets snapGrid to [16, 16]', () => {
|
it('sets snapGrid to [8, 8]', () => {
|
||||||
render(<CanvasContainer />)
|
render(<CanvasContainer />)
|
||||||
expect(rfProps.snapGrid).toEqual([16, 16])
|
expect(rfProps.snapGrid).toEqual([8, 8])
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Delete key ────────────────────────────────────────────────────────────
|
// ── Delete key ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||||
|
|
||||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||||
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
const [edgePath, labelX] = data?.path_style === 'smooth'
|
||||||
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
||||||
: getBezierPath(pathArgs)
|
: getBezierPath(pathArgs)
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
{data?.label && (
|
{data?.label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
color: theme.colors.edgeLabelColor,
|
color: theme.colors.edgeLabelColor,
|
||||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement, useEffect } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -8,6 +8,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp } from '@/utils/maskIp'
|
import { maskIp } from '@/utils/maskIp'
|
||||||
|
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -18,7 +19,10 @@ function formatStorage(gb: number): string {
|
|||||||
return `${gb} GB`
|
return `${gb} GB`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||||
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
|
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
@@ -141,13 +145,26 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
|
|||||||
title={data.status}
|
title={data.status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
||||||
|
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
||||||
|
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
||||||
|
return (
|
||||||
|
<span key={sourceId}>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="bottom"
|
id={sourceId}
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={targetId}
|
||||||
|
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
@@ -75,11 +75,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Type */}
|
{/* Type + Icon on the same row */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -103,7 +103,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Icon</Label>
|
<Label className="text-xs text-muted-foreground">Icon</Label>
|
||||||
{form.custom_icon && (
|
{form.custom_icon && (
|
||||||
@@ -120,22 +120,25 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIconPickerOpen((o) => !o)}
|
onClick={() => setIconPickerOpen((o) => !o)}
|
||||||
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors"
|
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
{(() => {
|
{(() => {
|
||||||
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></>
|
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||||
}
|
}
|
||||||
return <span className="text-muted-foreground">Default (from type)</span>
|
const defaultIcon = NODE_TYPE_DEFAULT_ICONS[form.type as NodeType] ?? NODE_TYPE_DEFAULT_ICONS.generic
|
||||||
|
return <>{createElement(defaultIcon, { size: 13, className: 'text-muted-foreground shrink-0' })}<span className="text-muted-foreground truncate">Default</span></>
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||||
</button>
|
</button>
|
||||||
{/* Inline picker panel */}
|
</div>
|
||||||
|
|
||||||
|
{/* Inline icon picker — full width, shown below the type+icon row */}
|
||||||
{iconPickerOpen && (
|
{iconPickerOpen && (
|
||||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||||
<Input
|
<Input
|
||||||
value={iconSearch}
|
value={iconSearch}
|
||||||
onChange={(e) => setIconSearch(e.target.value)}
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
@@ -182,7 +185,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
@@ -414,6 +416,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</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">
|
||||||
|
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||||
|
<Select
|
||||||
|
value={String(form.bottom_handles ?? 1)}
|
||||||
|
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
<SelectItem value="1" className="text-sm">1 — center</SelectItem>
|
||||||
|
<SelectItem value="2" className="text-sm">2 — left / right</SelectItem>
|
||||||
|
<SelectItem value="3" className="text-sm">3 — left / center / right</SelectItem>
|
||||||
|
<SelectItem value="4" className="text-sm">4 — evenly spaced</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface PendingDevice {
|
|||||||
services: Service[]
|
services: Service[]
|
||||||
suggested_type: string | null
|
suggested_type: string | null
|
||||||
status: string
|
status: string
|
||||||
|
discovery_source: string | null
|
||||||
discovered_at: string
|
discovered_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
|
|
||||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
|
|
||||||
const handleApprove = () => { onApprove(device); onClose() }
|
const handleApprove = () => { onApprove(device) }
|
||||||
const handleHide = () => { onHide(device); onClose() }
|
const handleHide = () => { onHide(device); onClose() }
|
||||||
const handleIgnore = () => { onIgnore(device); onClose() }
|
const handleIgnore = () => { onIgnore(device); onClose() }
|
||||||
|
|
||||||
@@ -101,7 +102,10 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
{device.suggested_type && (
|
{device.suggested_type && (
|
||||||
<InfoRow label="Type" value={device.suggested_type} />
|
<InfoRow label="Type" value={device.suggested_type} />
|
||||||
)}
|
)}
|
||||||
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} />
|
{device.discovery_source && (
|
||||||
|
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
|
||||||
|
)}
|
||||||
|
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
|
|||||||
@@ -24,29 +24,22 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
|||||||
.catch(() => {/* use defaults */})
|
.catch(() => {/* use defaults */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleScanNow = async () => {
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await scanApi.saveConfig({ ranges: cleaned })
|
await scanApi.saveConfig({ ranges: cleaned })
|
||||||
toast.success('Scan config saved')
|
await scanApi.trigger()
|
||||||
|
onScanNow()
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save config')
|
toast.error('Failed to start scan')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanNow = async () => {
|
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
|
||||||
await handleSave()
|
|
||||||
onScanNow()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
||||||
@@ -99,7 +92,6 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
|||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleScanNow}
|
onClick={handleScanNow}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -1,34 +1,61 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { useReactFlow } from '@xyflow/react'
|
import { useReactFlow } from '@xyflow/react'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
interface SearchModalProps {
|
interface SearchModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onOpenPending: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchModal({ open, onClose }: SearchModalProps) {
|
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||||
const nodes = useCanvasStore((s) => s.nodes)
|
const nodes = useCanvasStore((s) => s.nodes)
|
||||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
|
||||||
|
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
|
||||||
n.data.label?.toLowerCase().includes(q) ||
|
n.data.label?.toLowerCase().includes(q) ||
|
||||||
n.data.ip?.toLowerCase().includes(q) ||
|
n.data.ip?.toLowerCase().includes(q) ||
|
||||||
n.data.hostname?.toLowerCase().includes(q)
|
n.data.hostname?.toLowerCase().includes(q)
|
||||||
).slice(0, 8)
|
).slice(0, 6)
|
||||||
|
|
||||||
const handleSelect = useCallback((nodeId: string) => {
|
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||||
|
d.ip.toLowerCase().includes(q) ||
|
||||||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.services.some((s) =>
|
||||||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
|
s.category?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
).slice(0, 4)
|
||||||
|
|
||||||
|
const totalResults = nodeResults.length + pendingResults.length
|
||||||
|
|
||||||
|
const handleSelectNode = useCallback((nodeId: string) => {
|
||||||
setSelectedNode(nodeId)
|
setSelectedNode(nodeId)
|
||||||
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||||
onClose()
|
onClose()
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}, [fitView, setSelectedNode, onClose])
|
}, [fitView, setSelectedNode, onClose])
|
||||||
|
|
||||||
|
const handleSelectPending = useCallback((deviceId: string) => {
|
||||||
|
onOpenPending(deviceId)
|
||||||
|
onClose()
|
||||||
|
setQuery('')
|
||||||
|
}, [onOpenPending, onClose])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,23 +70,24 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search nodes by label, IP, hostname…"
|
placeholder="Search nodes, pending devices by IP or service…"
|
||||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') { onClose(); setQuery('') }
|
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||||
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
|
||||||
|
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 && (
|
{totalResults > 0 && (
|
||||||
<ul className="py-1 max-h-64 overflow-y-auto">
|
<ul className="py-1 max-h-72 overflow-y-auto">
|
||||||
{results.map((node) => (
|
{nodeResults.map((node) => (
|
||||||
<li
|
<li
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
onClick={() => handleSelect(node.id)}
|
onClick={() => handleSelectNode(node.id)}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||||
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||||
@@ -68,15 +96,34 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||||
|
<li className="px-4 py-1">
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{pendingResults.map((device) => {
|
||||||
|
const serviceName = device.services.find((s) => s.service_name)?.service_name
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
|
onClick={() => handleSelectPending(device.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
|
||||||
|
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q.length > 0 && results.length === 0 && (
|
{q.length > 0 && totalResults === 0 && (
|
||||||
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
<p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q.length === 0 && (
|
{q.length === 0 && (
|
||||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices…</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,81 +1,312 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { NodeModal } from '../NodeModal'
|
import { NodeModal } from '../NodeModal'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
// ── Mock Shadcn Select with native <select> for testability ───────────────
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/select', () => ({
|
||||||
|
Select: ({ value, onValueChange, children }: {
|
||||||
|
value?: string; onValueChange?: (v: string) => void; children: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
SelectValue: () => null,
|
||||||
|
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
SelectGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
SelectLabel: () => null,
|
||||||
|
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
|
||||||
|
<option value={value}>{children}</option>
|
||||||
|
),
|
||||||
|
SelectSeparator: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderModal(props: Partial<Parameters<typeof NodeModal>[0]> = {}) {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||||
|
return { onClose, onSubmit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get <select> elements in document order: [0]=Type, [1]=CheckMethod, [2]=BottomHandles */
|
||||||
|
function selects() { return screen.getAllByRole('combobox') as HTMLSelectElement[] }
|
||||||
|
|
||||||
|
const BASE: Partial<NodeData> = {
|
||||||
|
type: 'server', label: 'My Server', hostname: 'server.lan',
|
||||||
|
ip: '192.168.1.10', check_method: 'ping', services: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('NodeModal', () => {
|
describe('NodeModal', () => {
|
||||||
|
|
||||||
|
// ── Visibility ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders nothing when closed', () => {
|
it('renders nothing when closed', () => {
|
||||||
const { container } = render(
|
const { container } = render(<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
|
||||||
)
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders form fields when open', () => {
|
it('renders form fields when open', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
||||||
expect(screen.getByText('Add Node')).toBeDefined()
|
expect(screen.getByText('Add Node')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not call onSubmit when label is empty and shows error', () => {
|
it('shows "Add" button for default title', () => {
|
||||||
const onSubmit = vi.fn()
|
renderModal()
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
expect(screen.getByRole('button', { name: 'Add' })).toBeDefined()
|
||||||
fireEvent.click(screen.getByText('Add'))
|
})
|
||||||
|
|
||||||
|
it('shows "Save" button when title is Edit Node', () => {
|
||||||
|
renderModal({ title: 'Edit Node' })
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills form from initial prop', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Cancel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
const { onClose } = renderModal()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
|
const { onSubmit } = renderModal()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect(onSubmit).not.toHaveBeenCalled()
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
expect(screen.getByText('Label is required')).toBeDefined()
|
expect(screen.getByText('Label is required')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onSubmit with form data when label is filled', () => {
|
it('blocks submit when label is whitespace only', () => {
|
||||||
const onSubmit = vi.fn()
|
const { onSubmit } = renderModal()
|
||||||
const onClose = vi.fn()
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: ' ' } })
|
||||||
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
fireEvent.click(screen.getByText('Add'))
|
|
||||||
expect(onSubmit).toHaveBeenCalledOnce()
|
|
||||||
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
|
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears label error when user starts typing', () => {
|
it('clears label error when user starts typing', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
fireEvent.click(screen.getByText('Add'))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect(screen.getByText('Label is required')).toBeDefined()
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
||||||
expect(screen.queryByText('Label is required')).toBeNull()
|
expect(screen.queryByText('Label is required')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills form from initial prop', () => {
|
// ── Form submission ───────────────────────────────────────────────────
|
||||||
render(
|
|
||||||
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
|
|
||||||
)
|
|
||||||
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
|
|
||||||
expect(input.value).toBe('Pre-filled')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Save button text when title is Edit Node', () => {
|
it('calls onSubmit and onClose with form data on valid submit', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
|
const { onSubmit, onClose } = renderModal({ initial: BASE })
|
||||||
expect(screen.getByText('Save')).toBeDefined()
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
})
|
expect(onSubmit).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
it('calls onClose when Cancel is clicked', () => {
|
|
||||||
const onClose = vi.fn()
|
|
||||||
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
|
|
||||||
fireEvent.click(screen.getByText('Cancel'))
|
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(data.label).toBe('My Server')
|
||||||
|
expect(data.type).toBe('server')
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Hardware section', () => {
|
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('Optional notes'), { target: { value: 'rack A' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(data.hostname).toBe('nas.local')
|
||||||
|
expect(data.ip).toBe('10.0.0.1')
|
||||||
|
expect(data.notes).toBe('rack A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits check_target', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_target).toBe('http://192.168.1.10:8080')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Type selector ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('pre-fills type from initial', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'router' } })
|
||||||
|
expect(selects()[0].value).toBe('router')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes type and submits it', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(selects()[0], { target: { value: 'nas' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Check method ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('pre-fills check_method from initial', () => {
|
||||||
|
renderModal({ initial: { ...BASE, check_method: 'http' } })
|
||||||
|
expect(selects()[1].value).toBe('http')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes check_method and submits it', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(selects()[1], { target: { value: 'ssh' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('ssh')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Icon picker ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows "Default" label when no custom icon', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('Default')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens icon picker on trigger button click', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
expect(screen.getByPlaceholderText('Search icons…')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes picker and shows icon label after selecting an icon', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
|
||||||
|
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||||
|
expect(screen.getByText('Database (SQL/NoSQL)')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits custom_icon key after picking', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).custom_icon).toBe('database')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Reset button when custom_icon is set', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
|
||||||
|
expect(screen.getByRole('button', { name: /Reset/i })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Reset button when no custom_icon', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.queryByRole('button', { name: /Reset/i })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets custom_icon and shows Default on Reset click', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Reset/i }))
|
||||||
|
expect(screen.getByText('Default')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters icons by search query', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
|
||||||
|
expect(screen.getByTitle('Grafana / Kibana')).toBeDefined()
|
||||||
|
expect(screen.queryByTitle('Router')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Container mode (proxmox only) ─────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows Container Mode toggle for proxmox type', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'proxmox' } })
|
||||||
|
expect(screen.getByText('Container Mode')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Container Mode for non-proxmox types', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles container_mode on click', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
||||||
|
|
||||||
|
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'vm' },
|
||||||
|
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||||
|
expect(screen.getByText('PVE-01')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'lxc' },
|
||||||
|
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Parent Proxmox for server type', () => {
|
||||||
|
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
|
||||||
|
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'vm' } })
|
||||||
|
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('border')).toBeDefined()
|
||||||
|
expect(screen.getByText('background')).toBeDefined()
|
||||||
|
expect(screen.getByText('icon')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default colors hint when no custom_colors', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Reset to defaults when custom_colors are set', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
|
||||||
|
expect(screen.getByText('Reset to defaults')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets custom_colors on Reset to defaults click', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
|
||||||
|
fireEvent.click(screen.getByText('Reset to defaults'))
|
||||||
|
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||||
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Hardware section ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders Hardware toggle button', () => {
|
it('renders Hardware toggle button', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
expect(screen.getByText('Hardware')).toBeDefined()
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hardware fields are hidden by default', () => {
|
it('hardware fields are hidden by default', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('expands hardware fields on toggle click', () => {
|
it('expands hardware fields on toggle click', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||||
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||||
@@ -83,88 +314,101 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.getByPlaceholderText('e.g. 500')).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', () => {
|
it('submits hardware fields when filled', () => {
|
||||||
const onSubmit = vi.fn()
|
const { onSubmit } = renderModal()
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
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. 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. 8'), { target: { value: '12' } })
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||||
fireEvent.click(screen.getByText('Add'))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
const submitted = onSubmit.mock.calls[0][0]
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
expect(submitted.cpu_model).toBe('Intel i7-12700K')
|
expect(data.cpu_model).toBe('Intel i7-12700K')
|
||||||
expect(submitted.cpu_count).toBe(12)
|
expect(data.cpu_count).toBe(12)
|
||||||
expect(submitted.ram_gb).toBe(64)
|
expect(data.ram_gb).toBe(64)
|
||||||
expect(submitted.disk_gb).toBe(2000)
|
expect(data.disk_gb).toBe(2000)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('auto-expands when initial has hardware data', () => {
|
it('hides Hardware section for groupRect type', () => {
|
||||||
render(
|
renderModal({ initial: { type: 'groupRect' } })
|
||||||
<NodeModal
|
|
||||||
open
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides hardware section for groupRect type', () => {
|
|
||||||
render(
|
|
||||||
<NodeModal
|
|
||||||
open
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
initial={{ type: 'groupRect' }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.queryByText('Hardware')).toBeNull()
|
expect(screen.queryByText('Hardware')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('show on node toggle is hidden when section is collapsed', () => {
|
it('show_hardware toggle hidden when section is collapsed', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
expect(screen.queryByText('Show on node')).toBeNull()
|
expect(screen.queryByText('Show on node')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('show on node toggle appears when section is expanded', () => {
|
it('show_hardware toggle appears when section is expanded', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
expect(screen.getByText('Show on node')).toBeDefined()
|
expect(screen.getByText('Show on node')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('show_hardware defaults to false', () => {
|
it('show_hardware defaults to falsy', () => {
|
||||||
const onSubmit = vi.fn()
|
const { onSubmit } = renderModal()
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||||
fireEvent.click(screen.getByText('Add'))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggling show on node sets show_hardware to true', () => {
|
it('toggling show_hardware sets it to true', () => {
|
||||||
const onSubmit = vi.fn()
|
const { onSubmit } = renderModal()
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
fireEvent.click(screen.getByRole('switch'))
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
fireEvent.click(screen.getByText('Add'))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills show_hardware from initial prop', () => {
|
it('pre-fills show_hardware from initial', () => {
|
||||||
const onSubmit = vi.fn()
|
const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
|
||||||
render(
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
<NodeModal
|
|
||||||
open
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
fireEvent.click(screen.getByText('Add'))
|
|
||||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Bottom connection points ───────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows Bottom Connection Points for server type', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Bottom Connection Points for groupRect', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||||
|
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Bottom Connection Points for group', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'group' } })
|
||||||
|
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults bottom_handles to 1', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(selects()[2].value).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills bottom_handles from initial', () => {
|
||||||
|
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||||
|
expect(selects()[2].value).toBe('3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits updated bottom_handles', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(selects()[2], { target: { value: '4' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe('PendingDeviceModal', () => {
|
|||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('calls onApprove with the device and onClose when Approve is clicked', () => {
|
it('calls onApprove with the device when Approve is clicked (parent controls close on success)', () => {
|
||||||
const device = makeDevice()
|
const device = makeDevice()
|
||||||
const onApprove = vi.fn()
|
const onApprove = vi.fn()
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
@@ -135,7 +135,7 @@ describe('PendingDeviceModal', () => {
|
|||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
||||||
expect(onApprove).toHaveBeenCalledWith(device)
|
expect(onApprove).toHaveBeenCalledWith(device)
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onHide with the device and onClose when Hide is clicked', () => {
|
it('calls onHide with the device and onClose when Hide is clicked', () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
|
|||||||
scanApi: {
|
scanApi: {
|
||||||
getConfig: vi.fn(),
|
getConfig: vi.fn(),
|
||||||
saveConfig: vi.fn(),
|
saveConfig: vi.fn(),
|
||||||
|
trigger: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
@@ -20,6 +21,8 @@ describe('ScanConfigModal', () => {
|
|||||||
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
||||||
vi.mocked(scanApi.saveConfig).mockReset()
|
vi.mocked(scanApi.saveConfig).mockReset()
|
||||||
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||||
|
vi.mocked(scanApi.trigger).mockReset()
|
||||||
|
vi.mocked(scanApi.trigger).mockResolvedValue({} as never)
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
})
|
})
|
||||||
@@ -38,16 +41,6 @@ describe('ScanConfigModal', () => {
|
|||||||
expect(input).toBeDefined()
|
expect(input).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves only ranges (interval managed by settings endpoint)', async () => {
|
|
||||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never)
|
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
|
||||||
await screen.findByDisplayValue('10.0.0.0/8')
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('adds a new empty range on "Add range" click', async () => {
|
it('adds a new empty range on "Add range" click', async () => {
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
@@ -59,62 +52,40 @@ describe('ScanConfigModal', () => {
|
|||||||
it('delete button disabled when only one range', async () => {
|
it('delete button disabled when only one range', async () => {
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
// Only 1 range → delete button disabled
|
|
||||||
const trashButtons = document.querySelectorAll('button[disabled]')
|
const trashButtons = document.querySelectorAll('button[disabled]')
|
||||||
expect(trashButtons.length).toBeGreaterThan(0)
|
expect(trashButtons.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can remove a range when more than one exist', async () => {
|
it('can remove a range when more than one exist', async () => {
|
||||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never)
|
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'] } } as never)
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
// Both trash buttons should be enabled
|
|
||||||
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
|
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
|
||||||
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
|
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error toast and does not save when all ranges are empty', async () => {
|
it('shows error toast and does not save when all ranges are empty', async () => {
|
||||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } as never)
|
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''] } } as never)
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
|
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
|
||||||
})
|
})
|
||||||
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves config and closes on Save click', async () => {
|
it('saves config, triggers scan, calls onScanNow and closes on "Scan Now" click', async () => {
|
||||||
const onClose = vi.fn()
|
|
||||||
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
|
|
||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
|
||||||
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
|
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error toast when save fails', async () => {
|
|
||||||
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
|
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
|
||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls onScanNow after saving on "Scan Now" click', async () => {
|
|
||||||
const onScanNow = vi.fn()
|
const onScanNow = vi.fn()
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(scanApi.saveConfig).toHaveBeenCalled()
|
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||||
|
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
||||||
expect(onScanNow).toHaveBeenCalledOnce()
|
expect(onScanNow).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -126,12 +97,11 @@ describe('ScanConfigModal', () => {
|
|||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('strips whitespace from ranges before saving', async () => {
|
it('strips whitespace from ranges before scanning', async () => {
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
// Type a range with surrounding whitespace
|
|
||||||
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
|
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(scanApi.saveConfig).toHaveBeenCalledWith(
|
expect(scanApi.saveConfig).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
|
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
useReactFlow: () => ({ fitView: mockFitView }),
|
useReactFlow: () => ({ fitView: mockFitView }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockOnOpenPending = vi.fn()
|
||||||
|
|
||||||
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders nothing when closed', () => {
|
it('renders nothing when closed', () => {
|
||||||
render(<SearchModal open={false} onClose={vi.fn()} />)
|
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders search input when open', () => {
|
it('renders search input when open', () => {
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows "Type to search" hint when query is empty', () => {
|
it('shows "Type to search" hint when query is empty', () => {
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
expect(screen.getByText(/type to search/i)).toBeDefined()
|
expect(screen.getByText(/type to search/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows no results message when query has no matches', () => {
|
it('shows no results message when query has no matches', () => {
|
||||||
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
||||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters nodes by label', () => {
|
it('filters nodes by label', () => {
|
||||||
useCanvasStore.setState({
|
useCanvasStore.setState({
|
||||||
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
||||||
expect(screen.getByText('My Router')).toBeDefined()
|
expect(screen.getByText('My Router')).toBeDefined()
|
||||||
expect(screen.queryByText('NAS Server')).toBeNull()
|
expect(screen.queryByText('NAS Server')).toBeNull()
|
||||||
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
|
|||||||
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
||||||
expect(screen.getByText('Box A')).toBeDefined()
|
expect(screen.getByText('Box A')).toBeDefined()
|
||||||
expect(screen.queryByText('Box B')).toBeNull()
|
expect(screen.queryByText('Box B')).toBeNull()
|
||||||
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
|
|||||||
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
||||||
expect(screen.getByText('A')).toBeDefined()
|
expect(screen.getByText('A')).toBeDefined()
|
||||||
expect(screen.queryByText('B')).toBeNull()
|
expect(screen.queryByText('B')).toBeNull()
|
||||||
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
|
|||||||
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
||||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('limits results to 8 nodes', () => {
|
it('limits node results to 6', () => {
|
||||||
useCanvasStore.setState({
|
useCanvasStore.setState({
|
||||||
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
||||||
const items = screen.getAllByText(/Server \d/)
|
const items = screen.getAllByText(/Server \d/)
|
||||||
expect(items).toHaveLength(8)
|
expect(items).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selects node and closes on result click', () => {
|
it('selects node and closes on result click', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
||||||
fireEvent.click(screen.getByText('Proxmox'))
|
fireEvent.click(screen.getByText('Proxmox'))
|
||||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||||
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
|
|||||||
it('selects first result and closes on Enter key', () => {
|
it('selects first result and closes on Enter key', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
const input = screen.getByPlaceholderText(/search nodes/i)
|
const input = screen.getByPlaceholderText(/search nodes/i)
|
||||||
fireEvent.change(input, { target: { value: 'switch' } })
|
fireEvent.change(input, { target: { value: 'switch' } })
|
||||||
fireEvent.keyDown(input, { key: 'Enter' })
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
|
|||||||
|
|
||||||
it('closes on Escape key', () => {
|
it('closes on Escape key', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes when clicking backdrop', () => {
|
it('closes when clicking backdrop', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
// The backdrop is the fixed inset div — clicking it fires onClose
|
// The backdrop is the fixed inset div — clicking it fires onClose
|
||||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
||||||
fireEvent.click(backdrop)
|
fireEvent.click(backdrop)
|
||||||
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
|
|||||||
|
|
||||||
it('does not close when clicking inside the search box', () => {
|
it('does not close when clicking inside the search box', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
||||||
expect(onClose).not.toHaveBeenCalled()
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('search is case-insensitive', () => {
|
it('search is case-insensitive', () => {
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
||||||
expect(screen.getByText('My NAS')).toBeDefined()
|
expect(screen.getByText('My NAS')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const handleAddService = () => {
|
const handleAddService = () => {
|
||||||
const port = parseInt(newSvc.port, 10)
|
const port = parseInt(newSvc.port, 10)
|
||||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||||
|
snapshotHistory()
|
||||||
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
||||||
updateNode(node.id, { services: [...services, svc] })
|
updateNode(node.id, { services: [...services, svc] })
|
||||||
setNewSvc(EMPTY_FORM)
|
setNewSvc(EMPTY_FORM)
|
||||||
@@ -92,6 +93,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveService = (index: number) => {
|
const handleRemoveService = (index: number) => {
|
||||||
|
snapshotHistory()
|
||||||
const updated = services.filter((_, i) => i !== index)
|
const updated = services.filter((_, i) => i !== index)
|
||||||
updateNode(node.id, { services: updated })
|
updateNode(node.id, { services: updated })
|
||||||
if (editingIndex === index) setEditingFor(null)
|
if (editingIndex === index) setEditingFor(null)
|
||||||
@@ -109,6 +111,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
if (editingIndex === null) return
|
if (editingIndex === null) return
|
||||||
const port = parseInt(editSvc.port, 10)
|
const port = parseInt(editSvc.port, 10)
|
||||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||||
|
snapshotHistory()
|
||||||
const updated = services.map((svc, i) =>
|
const updated = services.map((svc, i) =>
|
||||||
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
||||||
)
|
)
|
||||||
@@ -147,7 +150,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
|
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
@@ -35,26 +36,26 @@ interface SidebarProps {
|
|||||||
onScan: () => void
|
onScan: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
onNodeApproved: (nodeId: string) => void
|
||||||
|
forceView?: SidebarView
|
||||||
|
highlightPendingId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [_collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>('canvas')
|
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||||
|
|
||||||
|
// When forceView is set, override local state without useEffect
|
||||||
|
const collapsed = forceView ? false : _collapsed
|
||||||
|
const activeView = forceView ?? _activeView
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
const handleScan = useCallback(async () => {
|
const handleScan = useCallback(() => {
|
||||||
try {
|
|
||||||
await scanApi.trigger()
|
|
||||||
toast.success('Network scan started — check Scan History for results')
|
|
||||||
setActiveView('history')
|
|
||||||
onScan()
|
onScan()
|
||||||
} catch {
|
|
||||||
toast.error('Failed to trigger scan')
|
|
||||||
}
|
|
||||||
}, [onScan])
|
}, [onScan])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +93,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
{/* View content (only when expanded) */}
|
{/* View content (only when expanded) */}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
{!collapsed && activeView !== 'canvas' && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
|
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
|
||||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
{activeView === 'history' && <ScanHistoryPanel />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
@@ -152,15 +153,20 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && <VersionBadge />}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: string) => void }) {
|
const COMMON_PORTS = new Set([22, 80, 443])
|
||||||
|
|
||||||
|
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -174,12 +180,27 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
try {
|
||||||
|
await scanApi.clearPending()
|
||||||
|
setDevices([])
|
||||||
|
toast.success('Pending devices cleared')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to clear pending devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scanEventTs > 0) load()
|
if (scanEventTs > 0) load()
|
||||||
}, [scanEventTs, load])
|
}, [scanEventTs, load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightId || loading) return
|
||||||
|
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, [highlightId, loading])
|
||||||
|
|
||||||
const handleApprove = async (device: PendingDevice) => {
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
try {
|
try {
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
@@ -200,6 +221,7 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
|||||||
})
|
})
|
||||||
toast.success(`Approved ${nodeData.label}`)
|
toast.success(`Approved ${nodeData.label}`)
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
onNodeApproved(nodeId)
|
onNodeApproved(nodeId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to approve device')
|
toast.error('Failed to approve device')
|
||||||
@@ -230,16 +252,22 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
|||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
<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} />
|
<RefreshCw size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||||
{!loading && devices.length === 0 && (
|
{!loading && devices.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||||
)}
|
)}
|
||||||
{devices.map((d) => {
|
{devices.map((d) => {
|
||||||
const COMMON_PORTS = new Set([22, 80, 443])
|
|
||||||
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
|
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
|
||||||
const titleService = namedService
|
const titleService = namedService
|
||||||
?? d.services.find((s) => s.port === 80)
|
?? d.services.find((s) => s.port === 80)
|
||||||
@@ -252,11 +280,15 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
|||||||
const hasHttps = d.services.some((s) => s.port === 443)
|
const hasHttps = d.services.some((s) => s.port === 443)
|
||||||
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
|
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
|
||||||
const virtualBadge = detectVirtualBadge(d.mac)
|
const virtualBadge = detectVirtualBadge(d.mac)
|
||||||
|
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
||||||
|
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
||||||
|
const isHighlighted = d.id === highlightId
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={d.id}
|
key={d.id}
|
||||||
|
ref={isHighlighted ? highlightRef : null}
|
||||||
onClick={() => setSelected(d)}
|
onClick={() => setSelected(d)}
|
||||||
className="w-full mb-1.5 p-2 rounded-md bg-[#21262d] text-xs text-left hover:bg-[#30363d] transition-colors border border-transparent 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]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
||||||
@@ -265,8 +297,9 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
|||||||
{showIpBelow && (
|
{showIpBelow && (
|
||||||
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
|
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
|
||||||
)}
|
)}
|
||||||
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge) && (
|
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
|
||||||
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
|
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
|
||||||
|
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
||||||
{virtualBadge && (
|
{virtualBadge && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
@@ -447,7 +480,7 @@ function ScanHistoryPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||||
{new Date(r.started_at).toLocaleString()}
|
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
{r.ranges.length > 0 && (
|
{r.ranges.length > 0 && (
|
||||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||||
@@ -518,6 +551,34 @@ function SettingsPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VersionBadge() {
|
||||||
|
const current = __APP_VERSION__
|
||||||
|
const { latest, hasUpdate } = useLatestRelease(current)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
|
||||||
|
<a
|
||||||
|
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
v{current}
|
||||||
|
</a>
|
||||||
|
{hasUpdate && latest && (
|
||||||
|
<a
|
||||||
|
href={latest.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
|
||||||
|
>
|
||||||
|
↑ v{latest.version} available
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
||||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
||||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { Sidebar } from '../Sidebar'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
|
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
stop: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
settingsApi: {
|
||||||
|
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
|
save: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/Logo', () => ({
|
||||||
|
Logo: ({ showText }: { showText: boolean }) => (
|
||||||
|
<div data-testid="logo" data-show-text={showText} />
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/tooltip', () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipContent: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||||
|
PendingDeviceModal: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: id, type, status, services: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockToggleHideIp = vi.fn()
|
||||||
|
|
||||||
|
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
hideIp: false,
|
||||||
|
toggleHideIp: mockToggleHideIp,
|
||||||
|
addNode: vi.fn(),
|
||||||
|
scanEventTs: 0,
|
||||||
|
...overrides,
|
||||||
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onAddNode: vi.fn(),
|
||||||
|
onAddGroupRect: vi.fn(),
|
||||||
|
onScan: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onNodeApproved: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockStore()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('renders logo and nav items', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Add Node')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Save Canvas')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Scan Network')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows all view nav items', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Canvas')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Pending Devices')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Scan History')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('displays total / online / offline counts from store', () => {
|
||||||
|
mockStore({
|
||||||
|
nodes: [
|
||||||
|
makeNode('n1', 'online'),
|
||||||
|
makeNode('n2', 'online'),
|
||||||
|
makeNode('n3', 'offline'),
|
||||||
|
makeNode('n4', 'unknown'),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
// Total (excludes groupRect)
|
||||||
|
expect(screen.getByText('4')).toBeInTheDocument()
|
||||||
|
// Online
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument()
|
||||||
|
// Offline
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes from stats', () => {
|
||||||
|
mockStore({
|
||||||
|
nodes: [
|
||||||
|
makeNode('n1', 'unknown'), // 1 real node, not online/offline
|
||||||
|
makeNode('zone', 'unknown', 'groupRect'),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
// Total row shows 1 (groupRect excluded), online/offline both 0
|
||||||
|
const totalRow = screen.getByText('Total').closest('div')!
|
||||||
|
expect(totalRow).toHaveTextContent('1')
|
||||||
|
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Collapse ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('collapses sidebar on toggle button click', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
const aside = screen.getByRole('complementary')
|
||||||
|
expect(aside).toHaveStyle({ width: '220px' })
|
||||||
|
|
||||||
|
const toggle = aside.querySelector('button')!
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(aside).toHaveStyle({ width: '48px' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides label text when collapsed', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
const aside = screen.getByRole('complementary')
|
||||||
|
const toggle = aside.querySelector('button')!
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides stats footer when collapsed', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Total')).toBeInTheDocument()
|
||||||
|
const toggle = screen.getByRole('complementary').querySelector('button')!
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(screen.queryByText('Total')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows logo with showText=false when collapsed', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
const logo = screen.getByTestId('logo')
|
||||||
|
expect(logo).toHaveAttribute('data-show-text', 'true')
|
||||||
|
const toggle = screen.getByRole('complementary').querySelector('button')!
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(logo).toHaveAttribute('data-show-text', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Action callbacks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onAddNode when Add Node is clicked', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Add Node'))
|
||||||
|
expect(defaultProps.onAddNode).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAddGroupRect when Add Zone is clicked', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Add Zone'))
|
||||||
|
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSave when Save Canvas is clicked', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Save Canvas'))
|
||||||
|
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toggleHideIp when Hide IPs is clicked', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Hide IPs'))
|
||||||
|
expect(mockToggleHideIp).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Show IPs label when hideIp is true', () => {
|
||||||
|
mockStore({ hideIp: true })
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Show IPs')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
||||||
|
mockStore({ hasUnsavedChanges: true })
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
// The badge is a span sibling of the Save Canvas button icon
|
||||||
|
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||||
|
const badge = saveBtn.querySelector('span.rounded-full')
|
||||||
|
expect(badge).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show unsaved badge when no changes', () => {
|
||||||
|
mockStore({ hasUnsavedChanges: false })
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||||
|
const badge = saveBtn.querySelector('span.rounded-full')
|
||||||
|
expect(badge).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Scan action ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Scan Network'))
|
||||||
|
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Pending Devices'))
|
||||||
|
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Hidden Devices'))
|
||||||
|
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows History panel when Scan History nav item is clicked', async () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Scan History'))
|
||||||
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles Settings panel on Settings click', async () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
// Click the nav button again to close (use role to avoid matching the panel heading)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||||
|
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { Sidebar } from '../Sidebar'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
|
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
stop: vi.fn().mockResolvedValue({}),
|
||||||
|
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
||||||
|
},
|
||||||
|
settingsApi: {
|
||||||
|
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
|
save: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
vi.mock('@/components/ui/Logo', () => ({ Logo: () => null }))
|
||||||
|
vi.mock('@/components/ui/tooltip', () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipContent: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
|
||||||
|
vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useLatestRelease', () => ({
|
||||||
|
useLatestRelease: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
hideIp: false,
|
||||||
|
toggleHideIp: vi.fn(),
|
||||||
|
addNode: vi.fn(),
|
||||||
|
scanEventTs: 0,
|
||||||
|
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<Sidebar
|
||||||
|
onAddNode={vi.fn()}
|
||||||
|
onAddGroupRect={vi.fn()}
|
||||||
|
onScan={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onNodeApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('VersionBadge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays the current app version', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links current version to its GitHub release page', () => {
|
||||||
|
renderSidebar()
|
||||||
|
const link = screen.getByText(`v${__APP_VERSION__}`).closest('a')
|
||||||
|
expect(link).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`,
|
||||||
|
)
|
||||||
|
expect(link).toHaveAttribute('target', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update badge when on latest version', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows update badge when a newer version is available', async () => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({
|
||||||
|
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
|
||||||
|
hasUpdate: true,
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update badge links to the latest release URL', async () => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({
|
||||||
|
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
|
||||||
|
hasUpdate: true,
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
await waitFor(() => {
|
||||||
|
const badge = screen.getByText('↑ v9.9.9 available').closest('a')
|
||||||
|
expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9')
|
||||||
|
expect(badge).toHaveAttribute('target', '_blank')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update badge when hasUpdate is false even if latest exists', () => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({
|
||||||
|
latest: { version: __APP_VERSION__, url: 'https://github.com' },
|
||||||
|
hasUpdate: false,
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
|
||||||
|
// Reset module between tests so the module-level cache is cleared
|
||||||
|
async function freshHook() {
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import('../useLatestRelease')
|
||||||
|
return mod.useLatestRelease
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT = '1.8.3'
|
||||||
|
|
||||||
|
function mockFetch(payload: unknown, ok = true) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
json: () => Promise.resolve(payload),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLatestRelease', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no update when latest version matches current', async () => {
|
||||||
|
mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => expect(result.current.latest).not.toBeNull())
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns update when latest version is newer', async () => {
|
||||||
|
mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => expect(result.current.hasUpdate).toBe(true))
|
||||||
|
expect(result.current.latest?.version).toBe('1.9.0')
|
||||||
|
expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips leading v from tag_name', async () => {
|
||||||
|
mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => expect(result.current.latest).not.toBeNull())
|
||||||
|
expect(result.current.latest?.version).toBe('2.0.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when API returns non-ok response', async () => {
|
||||||
|
mockFetch({ message: 'Not Found' }, false)
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
expect(result.current.latest).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when API returns missing tag_name', async () => {
|
||||||
|
mockFetch({ html_url: 'https://github.com/example' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when API returns missing html_url', async () => {
|
||||||
|
mockFetch({ tag_name: 'v2.0.0' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when fetch throws', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches only once when hook is mounted multiple times concurrently', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
// Mount all three before the fetch resolves — cache is set to 'pending' after first mount
|
||||||
|
const a = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
const b = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
const c = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(a.result.current.latest).not.toBeNull()
|
||||||
|
})
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
// All hooks see the same result once cache resolves
|
||||||
|
expect(b.result.current.hasUpdate).toBe(false)
|
||||||
|
expect(c.result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { useStatusPolling } from '../useStatusPolling'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
vi.mock('@/stores/authStore')
|
||||||
|
|
||||||
|
const mockUpdateNode = vi.fn()
|
||||||
|
const mockNotifyScanDeviceFound = vi.fn()
|
||||||
|
|
||||||
|
class MockWebSocket {
|
||||||
|
static instances: MockWebSocket[] = []
|
||||||
|
url: string
|
||||||
|
onopen: (() => void) | null = null
|
||||||
|
onmessage: ((e: { data: string }) => void) | null = null
|
||||||
|
onerror: ((e: unknown) => void) | null = null
|
||||||
|
send = vi.fn()
|
||||||
|
close = vi.fn()
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url
|
||||||
|
MockWebSocket.instances.push(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useStatusPolling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
MockWebSocket.instances = []
|
||||||
|
vi.stubGlobal('WebSocket', MockWebSocket)
|
||||||
|
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
updateNode: mockUpdateNode,
|
||||||
|
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
||||||
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
|
|
||||||
|
vi.mocked(useAuthStore).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
token: 'test-token',
|
||||||
|
} as ReturnType<typeof useAuthStore>)
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { protocol: 'http:', host: 'localhost:5173' },
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
mockUpdateNode.mockClear()
|
||||||
|
mockNotifyScanDeviceFound.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open WebSocket when not authenticated', () => {
|
||||||
|
vi.mocked(useAuthStore).mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
token: null,
|
||||||
|
} as ReturnType<typeof useAuthStore>)
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open WebSocket when token is missing', () => {
|
||||||
|
vi.mocked(useAuthStore).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
token: null,
|
||||||
|
} as ReturnType<typeof useAuthStore>)
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connects to correct ws:// URL', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1)
|
||||||
|
expect(MockWebSocket.instances[0].url).toBe('ws://localhost:5173/api/v1/status/ws/status')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses wss:// when page is served over https', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { protocol: 'https:', host: 'mylab.local' },
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends token as first message on open', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
ws.onopen?.()
|
||||||
|
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls updateNode with correct data on status message', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
ws.onmessage?.({
|
||||||
|
data: JSON.stringify({
|
||||||
|
node_id: 'node-1',
|
||||||
|
status: 'online',
|
||||||
|
checked_at: '2024-01-01T12:00:00Z',
|
||||||
|
response_time_ms: 42,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
|
||||||
|
status: 'online',
|
||||||
|
response_time_ms: 42,
|
||||||
|
last_seen: '2024-01-01T12:00:00Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets last_seen to undefined when status is offline', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
ws.onmessage?.({
|
||||||
|
data: JSON.stringify({
|
||||||
|
node_id: 'node-1',
|
||||||
|
status: 'offline',
|
||||||
|
checked_at: '2024-01-01T12:00:00Z',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
|
||||||
|
status: 'offline',
|
||||||
|
response_time_ms: undefined,
|
||||||
|
last_seen: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets response_time_ms to undefined when null in message', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
ws.onmessage?.({
|
||||||
|
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
|
||||||
|
})
|
||||||
|
expect(mockUpdateNode).toHaveBeenCalledWith(
|
||||||
|
'node-1',
|
||||||
|
expect.objectContaining({ response_time_ms: undefined }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls notifyScanDeviceFound on scan_device_found message', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
|
||||||
|
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
|
||||||
|
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores malformed JSON without throwing', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
|
||||||
|
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores messages with no node_id or status', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
|
||||||
|
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes WebSocket on unmount', () => {
|
||||||
|
const { unmount } = renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
unmount()
|
||||||
|
expect(ws.close).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface ReleaseInfo {
|
||||||
|
version: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: ReleaseInfo | null | 'error' | 'pending' = null
|
||||||
|
|
||||||
|
export function useLatestRelease(currentVersion: string) {
|
||||||
|
const [latest, setLatest] = useState<ReleaseInfo | null>(
|
||||||
|
cache && cache !== 'error' && cache !== 'pending' ? cache : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cache !== null) return
|
||||||
|
cache = 'pending'
|
||||||
|
fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', {
|
||||||
|
headers: { Accept: 'application/vnd.github+json' },
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) { cache = 'error'; return }
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (!data || typeof data.tag_name !== 'string' || !data.html_url) {
|
||||||
|
cache = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const version = data.tag_name.replace(/^v/, '')
|
||||||
|
const info: ReleaseInfo = { version, url: data.html_url }
|
||||||
|
cache = info
|
||||||
|
setLatest(info)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
cache = 'error'
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasUpdate = latest !== null && latest.version !== currentVersion
|
||||||
|
return { latest, hasUpdate }
|
||||||
|
}
|
||||||
@@ -57,6 +57,57 @@ describe('canvasStore', () => {
|
|||||||
expect(node?.data.ip).toBe('10.0.0.1')
|
expect(node?.data.ip).toBe('10.0.0.1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updateNode setting parent_id on container-mode proxmox sets parentId and relative position', () => {
|
||||||
|
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
|
||||||
|
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
|
||||||
|
useCanvasStore.getState().addNode(proxmox)
|
||||||
|
useCanvasStore.getState().addNode(lxc)
|
||||||
|
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
|
||||||
|
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
|
||||||
|
expect(node?.parentId).toBe('px1')
|
||||||
|
expect(node?.extent).toBe('parent')
|
||||||
|
// Position should be relative to parent (160-100=60, 180-100=80)
|
||||||
|
expect(node?.position.x).toBe(60)
|
||||||
|
expect(node?.position.y).toBe(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNode setting parent_id on non-container proxmox does NOT set React Flow parentId', () => {
|
||||||
|
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 100, y: 100 } }
|
||||||
|
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
|
||||||
|
useCanvasStore.getState().addNode(proxmox)
|
||||||
|
useCanvasStore.getState().addNode(lxc)
|
||||||
|
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
|
||||||
|
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
|
||||||
|
expect(node?.parentId).toBeUndefined()
|
||||||
|
expect(node?.extent).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
|
||||||
|
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
|
||||||
|
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.getState().addNode(proxmox)
|
||||||
|
useCanvasStore.getState().addNode(lxc)
|
||||||
|
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
|
||||||
|
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
|
||||||
|
expect(node?.parentId).toBeUndefined()
|
||||||
|
expect(node?.extent).toBeUndefined()
|
||||||
|
// Position should be absolute (100+30=130, 100+40=140)
|
||||||
|
expect(node?.position.x).toBe(130)
|
||||||
|
expect(node?.position.y).toBe(140)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNode with parent_id puts parents before children in array', () => {
|
||||||
|
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 10, y: 10 } }
|
||||||
|
useCanvasStore.getState().addNode(proxmox)
|
||||||
|
useCanvasStore.getState().addNode(lxc)
|
||||||
|
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const pxIdx = nodes.findIndex((n) => n.id === 'px1')
|
||||||
|
const lxcIdx = nodes.findIndex((n) => n.id === 'lxc1')
|
||||||
|
expect(pxIdx).toBeLessThan(lxcIdx)
|
||||||
|
})
|
||||||
|
|
||||||
it('deleteNode removes node and its connected edges', () => {
|
it('deleteNode removes node and its connected edges', () => {
|
||||||
const store = useCanvasStore.getState()
|
const store = useCanvasStore.getState()
|
||||||
store.addNode(makeNode('n1'))
|
store.addNode(makeNode('n1'))
|
||||||
@@ -202,11 +253,11 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
|
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setSelectedNode(id) preserves existing selectedNodeIds', () => {
|
it('setSelectedNode(id) sets selectedNodeIds to [id], clearing multi-selection', () => {
|
||||||
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
|
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
|
||||||
useCanvasStore.getState().setSelectedNode('n1')
|
useCanvasStore.getState().setSelectedNode('n1')
|
||||||
// does NOT wipe selectedNodeIds when setting a specific id
|
// Single node click resets multi-selection to just the clicked node
|
||||||
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2'])
|
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1'])
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── createGroup ───────────────────────────────────────────────────────────
|
// ── createGroup ───────────────────────────────────────────────────────────
|
||||||
@@ -567,4 +618,61 @@ describe('canvasStore', () => {
|
|||||||
expect(stored?.width).toBeUndefined()
|
expect(stored?.width).toBeUndefined()
|
||||||
expect(stored?.height).toBeUndefined()
|
expect(stored?.height).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── bottom_handles edge remapping ──────────────────────────────────────────
|
||||||
|
|
||||||
|
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
|
||||||
|
const node = makeNode('n1', { bottom_handles: 4 })
|
||||||
|
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' }
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
|
||||||
|
|
||||||
|
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
|
expect(updated?.sourceHandle).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remaps target edges to "bottom" when bottom_handles is reduced', () => {
|
||||||
|
const node = makeNode('n2', { bottom_handles: 3 })
|
||||||
|
const edge = { ...makeEdge('e1', 'n1', 'n2'), targetHandle: 'bottom-3' }
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1'), node], edges: [edge] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n2', { bottom_handles: 1 })
|
||||||
|
|
||||||
|
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
|
expect(updated?.targetHandle).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not remap edges that are on handles still present after reduction', () => {
|
||||||
|
const node = makeNode('n1', { bottom_handles: 4 })
|
||||||
|
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-2' }
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { bottom_handles: 3 })
|
||||||
|
|
||||||
|
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
|
expect(updated?.sourceHandle).toBe('bottom-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not remap edges when bottom_handles increases', () => {
|
||||||
|
const node = makeNode('n1', { bottom_handles: 2 })
|
||||||
|
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { bottom_handles: 4 })
|
||||||
|
|
||||||
|
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
|
expect(updated?.sourceHandle).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never remaps the "bottom" handle itself', () => {
|
||||||
|
const node = makeNode('n1', { bottom_handles: 4 })
|
||||||
|
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 })
|
||||||
|
|
||||||
|
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
|
expect(updated?.sourceHandle).toBe('bottom')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ interface CanvasState {
|
|||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
markUnsaved: () => void
|
markUnsaved: () => void
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||||
|
fitViewPending: boolean
|
||||||
|
clearFitViewPending: () => void
|
||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
hideIp: boolean
|
hideIp: boolean
|
||||||
toggleHideIp: () => void
|
toggleHideIp: () => void
|
||||||
@@ -66,6 +69,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
hideIp: false,
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
|
fitViewPending: false,
|
||||||
|
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
@@ -149,10 +153,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const extra = connection as Connection & Partial<EdgeData>
|
const extra = connection as Connection & Partial<EdgeData>
|
||||||
const edgeType = extra.type ?? 'ethernet'
|
const edgeType = extra.type ?? 'ethernet'
|
||||||
// Normalize invisible stub handle IDs so React Flow can locate the handle
|
|
||||||
// and render the edge immediately (top-t / bottom-t are opacity:0 helpers).
|
|
||||||
const normalizeHandle = (h: string | null | undefined) =>
|
|
||||||
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
|
|
||||||
return {
|
return {
|
||||||
edges: addEdge({
|
edges: addEdge({
|
||||||
...connection,
|
...connection,
|
||||||
@@ -165,10 +165,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSelectedNode: (id) => set((state) => ({
|
setSelectedNode: (id) => set({
|
||||||
selectedNodeId: id,
|
selectedNodeId: id,
|
||||||
selectedNodeIds: id ? state.selectedNodeIds : [],
|
selectedNodeIds: id ? [id] : [],
|
||||||
})),
|
}),
|
||||||
|
|
||||||
addNode: (node) =>
|
addNode: (node) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -187,12 +187,65 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
updateNode: (id, data) =>
|
updateNode: (id, data) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
nodes: state.nodes.map((n) =>
|
let nodes = state.nodes.map((n) => {
|
||||||
n.id === id ? { ...n, data: { ...n.data, ...data } } : n
|
if (n.id !== id) return n
|
||||||
),
|
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
||||||
hasUnsavedChanges: true,
|
if ('parent_id' in data) {
|
||||||
})),
|
const newParentId = data.parent_id ?? undefined
|
||||||
|
if (!newParentId && n.parentId) {
|
||||||
|
// Detaching from a container: convert position back to absolute canvas coords
|
||||||
|
const parent = state.nodes.find((p) => p.id === n.parentId)
|
||||||
|
if (parent) {
|
||||||
|
updated.position = {
|
||||||
|
x: parent.position.x + n.position.x,
|
||||||
|
y: parent.position.y + n.position.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated.parentId = undefined
|
||||||
|
updated.extent = undefined
|
||||||
|
} else if (newParentId && newParentId !== n.parentId) {
|
||||||
|
const parent = state.nodes.find((p) => p.id === newParentId)
|
||||||
|
if (parent?.data.container_mode) {
|
||||||
|
// Attaching to a container-mode Proxmox: nest visually
|
||||||
|
updated.parentId = newParentId
|
||||||
|
updated.extent = 'parent' as const
|
||||||
|
// Convert absolute position to parent-relative (keep node visible inside)
|
||||||
|
updated.position = {
|
||||||
|
x: Math.max(10, n.position.x - parent.position.x),
|
||||||
|
y: Math.max(10, n.position.y - parent.position.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
// React Flow requires parent nodes to precede their children in the array
|
||||||
|
if ('parent_id' in data) {
|
||||||
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
|
const children = nodes.filter((n) => !!n.parentId)
|
||||||
|
nodes = [...parents, ...children]
|
||||||
|
}
|
||||||
|
// Remap edges when bottom_handles is reduced so no edge disappears
|
||||||
|
let edges = state.edges
|
||||||
|
if ('bottom_handles' in data && data.bottom_handles != null) {
|
||||||
|
const currentNode = state.nodes.find((n) => n.id === id)
|
||||||
|
const oldCount = currentNode?.data.bottom_handles ?? 1
|
||||||
|
const newCount = data.bottom_handles
|
||||||
|
if (newCount < oldCount) {
|
||||||
|
const removed = removedBottomHandleIds(oldCount, newCount)
|
||||||
|
edges = state.edges.map((e) => {
|
||||||
|
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
|
||||||
|
return { ...e, sourceHandle: 'bottom' }
|
||||||
|
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
|
||||||
|
return { ...e, targetHandle: 'bottom' }
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges, hasUnsavedChanges: true }
|
||||||
|
}),
|
||||||
|
|
||||||
deleteNode: (id) =>
|
deleteNode: (id) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -374,6 +427,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
// React Flow requires parents before children in the array
|
// React Flow requires parents before children in the array
|
||||||
const parents = nodes.filter((n) => !n.parentId)
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
const children = nodes.filter((n) => !!n.parentId)
|
const children = nodes.filter((n) => !!n.parentId)
|
||||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] })
|
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearFitViewPending: () => set({ fitViewPending: false }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
|
bottom_handles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
BOTTOM_HANDLE_IDS,
|
||||||
|
BOTTOM_HANDLE_POSITIONS,
|
||||||
|
normalizeHandle,
|
||||||
|
removedBottomHandleIds,
|
||||||
|
} from '../handleUtils'
|
||||||
|
|
||||||
|
describe('BOTTOM_HANDLE_IDS', () => {
|
||||||
|
it('first id is always "bottom" for backward compatibility', () => {
|
||||||
|
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has ids for 1–4 handles', () => {
|
||||||
|
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
|
||||||
|
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BOTTOM_HANDLE_POSITIONS', () => {
|
||||||
|
it('1 handle is centered at 50%', () => {
|
||||||
|
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2 handles are symmetric', () => {
|
||||||
|
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
|
||||||
|
expect(a).toBeLessThan(50)
|
||||||
|
expect(b).toBeGreaterThan(50)
|
||||||
|
expect(a + b).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 handles include a center at 50%', () => {
|
||||||
|
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
|
||||||
|
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 handles are evenly spaced', () => {
|
||||||
|
const pos = BOTTOM_HANDLE_POSITIONS[4]
|
||||||
|
expect(pos).toHaveLength(4)
|
||||||
|
// All values should be between 0 and 100 exclusive
|
||||||
|
pos.forEach((p) => {
|
||||||
|
expect(p).toBeGreaterThan(0)
|
||||||
|
expect(p).toBeLessThan(100)
|
||||||
|
})
|
||||||
|
// Positions should be strictly increasing
|
||||||
|
for (let i = 1; i < pos.length; i++) {
|
||||||
|
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeHandle', () => {
|
||||||
|
it('returns null for null/undefined', () => {
|
||||||
|
expect(normalizeHandle(null)).toBeNull()
|
||||||
|
expect(normalizeHandle(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps top-t → top', () => {
|
||||||
|
expect(normalizeHandle('top-t')).toBe('top')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps bottom-t → bottom', () => {
|
||||||
|
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps bottom-2-t → bottom-2', () => {
|
||||||
|
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps bottom-3-t → bottom-3', () => {
|
||||||
|
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps bottom-4-t → bottom-4', () => {
|
||||||
|
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through non-stub handles unchanged', () => {
|
||||||
|
expect(normalizeHandle('top')).toBe('top')
|
||||||
|
expect(normalizeHandle('bottom')).toBe('bottom')
|
||||||
|
expect(normalizeHandle('bottom-2')).toBe('bottom-2')
|
||||||
|
expect(normalizeHandle('custom-handle')).toBe('custom-handle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removedBottomHandleIds', () => {
|
||||||
|
it('returns empty set when count does not decrease', () => {
|
||||||
|
expect(removedBottomHandleIds(2, 2).size).toBe(0)
|
||||||
|
expect(removedBottomHandleIds(1, 4).size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
||||||
|
const removed = removedBottomHandleIds(4, 1)
|
||||||
|
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 → 2 removes bottom-3, bottom-4', () => {
|
||||||
|
const removed = removedBottomHandleIds(4, 2)
|
||||||
|
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 → 2 removes only bottom-3', () => {
|
||||||
|
const removed = removedBottomHandleIds(3, 2)
|
||||||
|
expect(removed).toEqual(new Set(['bottom-3']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never removes "bottom" (index 0)', () => {
|
||||||
|
const removed = removedBottomHandleIds(4, 1)
|
||||||
|
expect(removed.has('bottom')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { parseYamlToCanvas } from '../importYaml'
|
import { parseYamlToCanvas } from '../importYaml'
|
||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
@@ -73,6 +73,38 @@ describe('parseYamlToCanvas', () => {
|
|||||||
expect(nodes[0].data.show_hardware).toBeUndefined()
|
expect(nodes[0].data.show_hardware).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('links edges have bottom→top-t handles', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: switch
|
||||||
|
label: "SW"
|
||||||
|
links:
|
||||||
|
- label: "SRV"
|
||||||
|
linkType: ethernet
|
||||||
|
- nodeType: server
|
||||||
|
label: "SRV"
|
||||||
|
`
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].sourceHandle).toBe('bottom')
|
||||||
|
expect(edges[0].targetHandle).toBe('top-t')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cluster edges have cluster-right→cluster-left handles', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
clusterR:
|
||||||
|
label: "PVE2"
|
||||||
|
linkType: ethernet
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
`
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].sourceHandle).toBe('cluster-right')
|
||||||
|
expect(edges[0].targetHandle).toBe('cluster-left')
|
||||||
|
})
|
||||||
|
|
||||||
it('parent relationship sets parentId and creates an edge', () => {
|
it('parent relationship sets parentId and creates an edge', () => {
|
||||||
const yaml = `
|
const yaml = `
|
||||||
- nodeType: proxmox
|
- nodeType: proxmox
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import { normalizeHandle } from '@/utils/handleUtils'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export interface ApiNode extends Record<string, unknown> {
|
|||||||
show_hardware?: boolean
|
show_hardware?: boolean
|
||||||
width?: number | null
|
width?: number | null
|
||||||
height?: number | null
|
height?: number | null
|
||||||
|
bottom_handles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiEdge {
|
export interface ApiEdge {
|
||||||
@@ -99,14 +101,12 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
|||||||
show_hardware: n.data.show_hardware ?? false,
|
show_hardware: n.data.show_hardware ?? false,
|
||||||
width: n.width ?? null,
|
width: n.width ?? null,
|
||||||
height: n.height ?? null,
|
height: n.height ?? null,
|
||||||
|
bottom_handles: n.data.bottom_handles ?? 1,
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeHandle = (h: string | null | undefined): string | null =>
|
|
||||||
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
|
|
||||||
|
|
||||||
export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Bottom handle configuration for multi-handle nodes.
|
||||||
|
*
|
||||||
|
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||||
|
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
|
||||||
|
*
|
||||||
|
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||||
|
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
|
||||||
|
|
||||||
|
/** Left % position for each handle slot, per count. */
|
||||||
|
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
|
||||||
|
1: [50],
|
||||||
|
2: [25, 75],
|
||||||
|
3: [20, 50, 80],
|
||||||
|
4: [15, 38, 62, 85],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw handle ID coming from a React Flow connection event.
|
||||||
|
* Invisible target handles (e.g. 'bottom-2-t') are mapped to their source
|
||||||
|
* counterpart ('bottom-2') so the stored edge ID is stable and consistent.
|
||||||
|
*/
|
||||||
|
export function normalizeHandle(h: string | null | undefined): string | null {
|
||||||
|
if (!h) return null
|
||||||
|
if (h === 'top-t') return 'top'
|
||||||
|
// 'bottom-t' → 'bottom', 'bottom-2-t' → 'bottom-2', etc.
|
||||||
|
const m = h.match(/^(bottom(?:-\d+)?)-t$/)
|
||||||
|
if (m) return m[1]
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of handle IDs that are removed when bottom_handles
|
||||||
|
* is reduced from `oldCount` to `newCount`.
|
||||||
|
*/
|
||||||
|
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
||||||
|
const removed = new Set<string>()
|
||||||
|
for (let i = newCount; i < oldCount; i++) {
|
||||||
|
removed.add(BOTTOM_HANDLE_IDS[i])
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
@@ -34,16 +34,16 @@ export function parseYamlToCanvas(
|
|||||||
const yamlNodes: YamlNode[] = []
|
const yamlNodes: YamlNode[] = []
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const raw = entry as Record<string, unknown>
|
const entryRecord = entry as Record<string, unknown>
|
||||||
|
|
||||||
if (!raw.nodeType || typeof raw.nodeType !== 'string') {
|
if (!entryRecord.nodeType || typeof entryRecord.nodeType !== 'string') {
|
||||||
throw new Error(`Each YAML entry must have a "nodeType" string field`)
|
throw new Error(`Each YAML entry must have a "nodeType" string field`)
|
||||||
}
|
}
|
||||||
if (!raw.label || typeof raw.label !== 'string') {
|
if (!entryRecord.label || typeof entryRecord.label !== 'string') {
|
||||||
throw new Error(`Each YAML entry must have a "label" string field`)
|
throw new Error(`Each YAML entry must have a "label" string field`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const yn = raw as unknown as YamlNode
|
const yn = entryRecord as unknown as YamlNode
|
||||||
|
|
||||||
// Skip if a node with this label already exists on the canvas
|
// Skip if a node with this label already exists on the canvas
|
||||||
if (labelToId.has(yn.label)) {
|
if (labelToId.has(yn.label)) {
|
||||||
@@ -95,6 +95,8 @@ export function parseYamlToCanvas(
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
conn: YamlNodeConnection,
|
conn: YamlNodeConnection,
|
||||||
|
sourceHandle = 'bottom',
|
||||||
|
targetHandle = 'top-t',
|
||||||
) {
|
) {
|
||||||
const key = `${sourceId}|${targetId}`
|
const key = `${sourceId}|${targetId}`
|
||||||
const reverseKey = `${targetId}|${sourceId}`
|
const reverseKey = `${targetId}|${sourceId}`
|
||||||
@@ -105,6 +107,8 @@ export function parseYamlToCanvas(
|
|||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
|
sourceHandle,
|
||||||
|
targetHandle,
|
||||||
type: edgeType,
|
type: edgeType,
|
||||||
data: {
|
data: {
|
||||||
type: edgeType,
|
type: edgeType,
|
||||||
@@ -126,8 +130,8 @@ export function parseYamlToCanvas(
|
|||||||
node.data = { ...node.data, parent_id: parentId }
|
node.data = { ...node.data, parent_id: parentId }
|
||||||
node.parentId = parentId
|
node.parentId = parentId
|
||||||
node.extent = 'parent'
|
node.extent = 'parent'
|
||||||
// Also create an edge
|
// Also create an edge (parent bottom → child top)
|
||||||
addEdgeIfNew(parentId, node.id, yn.parent)
|
addEdgeIfNew(parentId, node.id, yn.parent, 'bottom', 'top-t')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +141,7 @@ export function parseYamlToCanvas(
|
|||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
console.warn(`[importYaml] links label not found: "${link.label}" — skipping`)
|
console.warn(`[importYaml] links label not found: "${link.label}" — skipping`)
|
||||||
} else {
|
} else {
|
||||||
addEdgeIfNew(node.id, targetId, link)
|
addEdgeIfNew(node.id, targetId, link, 'bottom', 'top-t')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +151,7 @@ export function parseYamlToCanvas(
|
|||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
|
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
|
||||||
} else {
|
} else {
|
||||||
addEdgeIfNew(node.id, targetId, yn.clusterR)
|
addEdgeIfNew(node.id, targetId, yn.clusterR, 'cluster-right', 'cluster-left')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +160,7 @@ export function parseYamlToCanvas(
|
|||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
|
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
|
||||||
} else {
|
} else {
|
||||||
addEdgeIfNew(sourceId, node.id, yn.clusterL)
|
addEdgeIfNew(sourceId, node.id, yn.clusterL, 'cluster-right', 'cluster-left')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { NodeType } from '@/types'
|
||||||
import {
|
import {
|
||||||
// Infrastructure (node types)
|
// Infrastructure (node types)
|
||||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
|
||||||
@@ -116,6 +117,27 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
|||||||
ICON_REGISTRY.map((e) => [e.key, e.icon]),
|
ICON_REGISTRY.map((e) => [e.key, e.icon]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||||
|
isp: Globe,
|
||||||
|
router: Router,
|
||||||
|
switch: Network,
|
||||||
|
server: Server,
|
||||||
|
proxmox: Layers,
|
||||||
|
vm: Box,
|
||||||
|
lxc: Container,
|
||||||
|
nas: HardDrive,
|
||||||
|
iot: Cpu,
|
||||||
|
ap: Wifi,
|
||||||
|
camera: Cctv,
|
||||||
|
printer: Printer,
|
||||||
|
computer: Monitor,
|
||||||
|
cpl: PlugZap,
|
||||||
|
docker: Anchor,
|
||||||
|
generic: Circle,
|
||||||
|
group: Circle,
|
||||||
|
groupRect: Circle,
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
||||||
export function resolveNodeIcon(
|
export function resolveNodeIcon(
|
||||||
typeIcon: LucideIcon,
|
typeIcon: LucideIcon,
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
@@ -2,8 +2,12 @@ import path from 'path'
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import pkg from './package.json'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Homelable — Proxmox VE LXC creator
|
|
||||||
# Run this on the Proxmox HOST (not inside a container):
|
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
|
||||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
|
||||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
|
||||||
step() { echo -e "\n${CYAN}▶ $*${NC}"; }
|
|
||||||
|
|
||||||
# ── Must run on a Proxmox VE host ─────────────────────────────────────────────
|
|
||||||
[[ $EUID -ne 0 ]] && error "Run as root on the Proxmox host"
|
|
||||||
command -v pct &>/dev/null || error "pct not found — run this on a Proxmox VE host, not inside a container"
|
|
||||||
|
|
||||||
# ── Detect available storages for LXC rootfs ──────────────────────────────────
|
|
||||||
mapfile -t STORAGES < <(pvesm status --content rootdir 2>/dev/null | awk 'NR>1 && $3=="active" {print $1}')
|
|
||||||
[[ ${#STORAGES[@]} -eq 0 ]] && error "No active storage found that supports LXC rootfs (rootdir content type)"
|
|
||||||
|
|
||||||
if [[ ${#STORAGES[@]} -eq 1 ]]; then
|
|
||||||
DEFAULT_STORAGE="${STORAGES[0]}"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "Available storages:"
|
|
||||||
for i in "${!STORAGES[@]}"; do
|
|
||||||
echo " $((i+1))) ${STORAGES[$i]}"
|
|
||||||
done
|
|
||||||
read -rp "Select storage [1]: " STORAGE_IDX
|
|
||||||
STORAGE_IDX="${STORAGE_IDX:-1}"
|
|
||||||
DEFAULT_STORAGE="${STORAGES[$((STORAGE_IDX-1))]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Settings (override via env vars) ──────────────────────────────────────────
|
|
||||||
CT_HOSTNAME="${CT_HOSTNAME:-homelable}"
|
|
||||||
STORAGE="${STORAGE:-$DEFAULT_STORAGE}"
|
|
||||||
DISK_SIZE="${DISK_SIZE:-8}" # GB
|
|
||||||
RAM="${RAM:-1024}" # MB
|
|
||||||
CORES="${CORES:-2}"
|
|
||||||
BRIDGE="${BRIDGE:-vmbr0}"
|
|
||||||
RAW="https://raw.githubusercontent.com/Pouzor/homelable/main"
|
|
||||||
|
|
||||||
# ── Interactive prompts ────────────────────────────────────────────────────────
|
|
||||||
DEFAULT_CTID="$(pvesh get /cluster/nextid 2>/dev/null || echo 200)"
|
|
||||||
|
|
||||||
if [[ -z "${CTID:-}" ]]; then
|
|
||||||
read -rp "Container ID [${DEFAULT_CTID}]: " CTID_INPUT
|
|
||||||
CTID="${CTID_INPUT:-$DEFAULT_CTID}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${ROOT_PASSWORD:-}" ]]; then
|
|
||||||
while true; do
|
|
||||||
read -rsp "Root password for LXC container: " ROOT_PASSWORD
|
|
||||||
echo ""
|
|
||||||
[[ -z "$ROOT_PASSWORD" ]] && warn "Password cannot be empty, try again." && continue
|
|
||||||
read -rsp "Confirm root password: " ROOT_PASSWORD_CONFIRM
|
|
||||||
echo ""
|
|
||||||
[[ "$ROOT_PASSWORD" == "$ROOT_PASSWORD_CONFIRM" ]] && break
|
|
||||||
warn "Passwords do not match, try again."
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
step "Creating Homelable LXC (CTID=$CTID, hostname=$CT_HOSTNAME, storage=$STORAGE)"
|
|
||||||
|
|
||||||
# ── Download Debian 12 template if needed ─────────────────────────────────────
|
|
||||||
TEMPLATE_STORAGE=$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')
|
|
||||||
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep "debian-12" | tail -1 | awk '{print $1}')
|
|
||||||
|
|
||||||
if [[ -z "$TEMPLATE" ]]; then
|
|
||||||
info "Downloading Debian 12 LXC template..."
|
|
||||||
pveam update
|
|
||||||
TEMPLATE_NAME=$(pveam available --section system | grep "debian-12" | tail -1 | awk '{print $2}')
|
|
||||||
[[ -z "$TEMPLATE_NAME" ]] && error "Could not find a Debian 12 template"
|
|
||||||
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE_NAME"
|
|
||||||
TEMPLATE="$TEMPLATE_STORAGE:vztmpl/$TEMPLATE_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Using template: $TEMPLATE"
|
|
||||||
|
|
||||||
# ── Create the container ───────────────────────────────────────────────────────
|
|
||||||
pct create "$CTID" "$TEMPLATE" \
|
|
||||||
--hostname "$CT_HOSTNAME" \
|
|
||||||
--storage "$STORAGE" \
|
|
||||||
--rootfs "${STORAGE}:${DISK_SIZE}" \
|
|
||||||
--memory "$RAM" \
|
|
||||||
--cores "$CORES" \
|
|
||||||
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \
|
|
||||||
--ostype debian \
|
|
||||||
--unprivileged 1 \
|
|
||||||
--features "nesting=1" \
|
|
||||||
--password "$ROOT_PASSWORD" \
|
|
||||||
--start 1
|
|
||||||
|
|
||||||
info "Container $CTID created and started"
|
|
||||||
|
|
||||||
# ── Wait for container to be ready ────────────────────────────────────────────
|
|
||||||
info "Waiting for container to be ready..."
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if pct exec "$CTID" -- test -x /usr/bin/apt-get &>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
[[ $i -eq 30 ]] && error "Container did not become ready after 30s"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Wait a bit more for network (DHCP lease)
|
|
||||||
info "Waiting for network (DHCP)..."
|
|
||||||
for i in $(seq 1 20); do
|
|
||||||
if pct exec "$CTID" -- sh -c "ip route | grep -q default" &>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
[[ $i -eq 20 ]] && error "Container has no default route after 20s — check bridge $BRIDGE"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Grant NET_RAW for nmap (ping-based checks) ─────────────────────────────────
|
|
||||||
echo "lxc.cap.keep = net_raw net_bind_service" >> "/etc/pve/lxc/${CTID}.conf" 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── Bootstrap curl then run the installer ─────────────────────────────────────
|
|
||||||
step "Running Homelable installer inside container $CTID..."
|
|
||||||
pct exec "$CTID" -- apt-get install -y -qq curl
|
|
||||||
pct exec "$CTID" -- bash -c "curl -fsSL ${RAW}/scripts/lxc-install.sh | bash"
|
|
||||||
|
|
||||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
|
||||||
IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "<container-ip>")
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}✓ Homelable installed in LXC $CTID${NC}"
|
|
||||||
echo -e " ${GREEN}✓ Open http://${IP}${NC}"
|
|
||||||
echo -e " Homelable login: ${YELLOW}admin / admin${NC}"
|
|
||||||
echo -e " LXC root SSH: ${YELLOW}root / <password you set>${NC}"
|
|
||||||
echo -e " ${YELLOW}⚠ Change the Homelable password after first login${NC}"
|
|
||||||
echo -e " ${YELLOW} - edit /opt/homelable/backend/.env (AUTH_PASSWORD_HASH)${NC}"
|
|
||||||
echo ""
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Homelable — in-container installer
|
|
||||||
# Runs INSIDE a Debian/Ubuntu LXC container (called automatically by install-proxmox.sh)
|
|
||||||
# Can also be run manually inside any Debian/Ubuntu machine:
|
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR=/opt/homelable
|
|
||||||
DATA_DIR=/opt/homelable/data
|
|
||||||
SERVICE_USER=homelable
|
|
||||||
REPO_URL="https://github.com/Pouzor/homelable.git"
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
|
||||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
|
||||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
|
||||||
|
|
||||||
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
|
||||||
|
|
||||||
# ── Detect OS ─────────────────────────────────────────────────────────────────
|
|
||||||
if [[ -f /etc/os-release ]]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. /etc/os-release
|
|
||||||
else
|
|
||||||
error "Cannot detect OS"
|
|
||||||
fi
|
|
||||||
info "Detected: $PRETTY_NAME"
|
|
||||||
[[ "$ID" =~ ^(debian|ubuntu)$ ]] || error "Requires Debian or Ubuntu"
|
|
||||||
|
|
||||||
# ── System deps ───────────────────────────────────────────────────────────────
|
|
||||||
info "Installing system dependencies..."
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --fix-missing python3 python3-pip python3-venv nmap curl git nginx
|
|
||||||
|
|
||||||
# ── Node.js 20 ────────────────────────────────────────────────────────────────
|
|
||||||
if ! command -v node &>/dev/null; then
|
|
||||||
info "Installing Node.js 20..."
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
|
||||||
apt-get install -y -qq nodejs
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Service user ──────────────────────────────────────────────────────────────
|
|
||||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
useradd --system --shell /sbin/nologin "$SERVICE_USER"
|
|
||||||
info "Created service user: $SERVICE_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Clone / update repo ───────────────────────────────────────────────────────
|
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
|
||||||
info "Updating existing installation..."
|
|
||||||
git -C "$INSTALL_DIR" pull --quiet
|
|
||||||
else
|
|
||||||
info "Cloning repository..."
|
|
||||||
git clone --quiet "$REPO_URL" "$INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$DATA_DIR"
|
|
||||||
|
|
||||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
|
||||||
info "Setting up Python backend..."
|
|
||||||
cd "$INSTALL_DIR/backend"
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install --quiet -r requirements.txt
|
|
||||||
|
|
||||||
# Generate .env if missing
|
|
||||||
if [[ ! -f .env ]]; then
|
|
||||||
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
|
|
||||||
# Default hash = bcrypt of "admin" (same as .env.example)
|
|
||||||
cat > .env <<EOF
|
|
||||||
SECRET_KEY=$SECRET
|
|
||||||
SQLITE_PATH=$DATA_DIR/homelab.db
|
|
||||||
CORS_ORIGINS=["http://localhost","http://$(hostname -I | awk '{print $1}')"]
|
|
||||||
|
|
||||||
# Auth — default credentials: admin / admin
|
|
||||||
# Change AUTH_PASSWORD_HASH before exposing on a network.
|
|
||||||
# Generate: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
|
||||||
AUTH_USERNAME=admin
|
|
||||||
AUTH_PASSWORD_HASH='\$2b\$12\$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
|
||||||
|
|
||||||
SCANNER_RANGES=["192.168.1.0/24"]
|
|
||||||
STATUS_CHECKER_INTERVAL=60
|
|
||||||
EOF
|
|
||||||
warn "Created .env with default admin/admin — change AUTH_PASSWORD_HASH before exposing on a network!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$DATA_DIR"
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR/backend/.venv"
|
|
||||||
|
|
||||||
# ── systemd: backend ──────────────────────────────────────────────────────────
|
|
||||||
cat > /etc/systemd/system/homelable-backend.service <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Homelable Backend
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=$SERVICE_USER
|
|
||||||
WorkingDirectory=$INSTALL_DIR/backend
|
|
||||||
EnvironmentFile=$INSTALL_DIR/backend/.env
|
|
||||||
ExecStart=$INSTALL_DIR/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ── Frontend ──────────────────────────────────────────────────────────────────
|
|
||||||
info "Building frontend..."
|
|
||||||
cd "$INSTALL_DIR/frontend"
|
|
||||||
npm ci --silent
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# ── nginx ─────────────────────────────────────────────────────────────────────
|
|
||||||
info "Configuring nginx..."
|
|
||||||
# Use the project nginx config, adjusted for local backend
|
|
||||||
sed \
|
|
||||||
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
|
||||||
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
|
||||||
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
|
||||||
|
|
||||||
ln -sf /etc/nginx/sites-available/homelable /etc/nginx/sites-enabled/homelable
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
|
||||||
nginx -t
|
|
||||||
systemctl reload nginx || systemctl start nginx
|
|
||||||
|
|
||||||
# ── Enable & start ────────────────────────────────────────────────────────────
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now homelable-backend
|
|
||||||
systemctl enable --now nginx
|
|
||||||
|
|
||||||
info "Done!"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}Homelable is running at http://$(hostname -I | awk '{print $1}')${NC}"
|
|
||||||
echo -e " Default login: admin / admin"
|
|
||||||
echo -e " ${YELLOW}⚠ Change the password: edit $INSTALL_DIR/backend/.env (AUTH_PASSWORD_HASH)${NC}"
|
|
||||||
echo ""
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Homelable — update to latest version
|
|
||||||
# Run inside the LXC / any Linux host where lxc-install.sh was used:
|
|
||||||
# bash /opt/homelable/scripts/update.sh
|
|
||||||
# Or pull-and-run directly:
|
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR=/opt/homelable
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
|
||||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
|
||||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
|
||||||
|
|
||||||
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
|
||||||
[[ -d "$INSTALL_DIR/.git" ]] || error "Homelable not found at $INSTALL_DIR — run lxc-install.sh first"
|
|
||||||
|
|
||||||
# ── Pull latest code ──────────────────────────────────────────────────────────
|
|
||||||
info "Pulling latest code..."
|
|
||||||
BEFORE=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
|
||||||
git -C "$INSTALL_DIR" pull --quiet
|
|
||||||
AFTER=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
|
||||||
|
|
||||||
if [[ "$BEFORE" == "$AFTER" ]]; then
|
|
||||||
info "Already up to date."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
info "Changes since last update:"
|
|
||||||
git -C "$INSTALL_DIR" log --oneline "${BEFORE}..${AFTER}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Stop backend ─────────────────────────────────────────────────────────────
|
|
||||||
info "Stopping backend service..."
|
|
||||||
systemctl stop homelable-backend
|
|
||||||
|
|
||||||
# ── Backend deps ─────────────────────────────────────────────────────────────
|
|
||||||
info "Updating Python dependencies..."
|
|
||||||
cd "$INSTALL_DIR/backend"
|
|
||||||
.venv/bin/pip install --quiet -r requirements.txt
|
|
||||||
|
|
||||||
# ── Frontend build ────────────────────────────────────────────────────────────
|
|
||||||
info "Rebuilding frontend..."
|
|
||||||
cd "$INSTALL_DIR/frontend"
|
|
||||||
npm ci --silent
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# ── nginx config ─────────────────────────────────────────────────────────────
|
|
||||||
info "Updating nginx config..."
|
|
||||||
sed \
|
|
||||||
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
|
||||||
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
|
||||||
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
|
||||||
nginx -t && systemctl reload nginx
|
|
||||||
|
|
||||||
# ── Restart backend ───────────────────────────────────────────────────────────
|
|
||||||
info "Starting backend service..."
|
|
||||||
systemctl start homelable-backend
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}Homelable updated successfully!${NC}"
|
|
||||||
echo -e " Running at http://$(hostname -I | awk '{print $1}')"
|
|
||||||
echo ""
|
|
||||||
Reference in New Issue
Block a user