Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eec0b3f9a | ||
|
|
b76519b0dd | ||
|
|
a2870a6e29 | ||
|
|
d6308a5573 | ||
|
|
fafbaeffcd | ||
|
|
824aa5d044 | ||
|
|
416b5fd1cb | ||
|
|
fcc2927b69 | ||
|
|
be46c21dd4 | ||
|
|
1c9d5791b7 | ||
|
|
0c7fd3c127 | ||
|
|
19758b2ba1 | ||
|
|
1f6812ef2a | ||
|
|
9db8b2a7a9 | ||
|
|
d40f73b85c | ||
|
|
71e29362aa | ||
|
|
7a5762e1a0 | ||
|
|
be2c5ce7e2 | ||
|
|
b4b22cc68f | ||
|
|
21465eee65 | ||
|
|
9b77e43ca1 | ||
|
|
e53197d2cb | ||
|
|
4393ac92c6 | ||
|
|
59079d90ca | ||
|
|
26e18dafdd | ||
|
|
c796cb39bd | ||
|
|
a93b0408b8 | ||
|
|
e40ac25b20 | ||
|
|
e93aef3871 | ||
|
|
79b682148e | ||
|
|
5040b70073 | ||
|
|
1343391b0d |
@@ -5,6 +5,34 @@ All notable changes to **Homelable** are documented here.
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.3.1] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- Approving a device no longer fails after an upgrade from 3.2.0: a node the 3.3.0 inventory backfill could not link kept the old device columns on `nodes`, and their NOT NULL constraint rejected every new node. (#352)
|
||||
|
||||
## [3.3.0] - 2026-08-15
|
||||
|
||||
### Features
|
||||
|
||||
- The Device Inventory is now the source of truth for device data: a node reads and edits its facts from its inventory row, and the device sheet is grouped by what a reader is after. (#339)
|
||||
- Per-service host override, so a service can point at a host other than the node's own. (#338)
|
||||
- A zone now parents the nodes dropped inside it. (#343)
|
||||
- Node properties accept an empty value. (#345)
|
||||
- Nodes can be given 0 connection points on top and bottom. (#336)
|
||||
- KVM switch added as a device type. (#335)
|
||||
- The sidebar version row links to the public changelog.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Alignment guides show for nodes inside groups and containers. (#337)
|
||||
- The status check interval is applied to the running scheduler. Thanks @JaG-v2. (#319)
|
||||
- Bumped undici and ip-address to patch high severity advisories. (#342)
|
||||
|
||||
### Docs
|
||||
|
||||
- The pre-built GHCR images are documented in the install guide.
|
||||
|
||||
## [3.2.0] - 2026-08-10
|
||||
|
||||
### Features
|
||||
|
||||
@@ -40,8 +40,33 @@ curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh |
|
||||
cd homelable && docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
## Pre-built Docker images
|
||||
|
||||
The quick starts above never build anything — `install.sh` writes `docker-compose.prebuilt.yml` (or `docker-compose.standalone.yml` with `--standalone`) as your `docker-compose.yml`, and both pull ready-made images. They are published to the GitHub Container Registry on every push to `main` and every `v*` tag, for `linux/amd64` and `linux/arm64`:
|
||||
|
||||
| Image | Contents |
|
||||
|---|---|
|
||||
| [`ghcr.io/pouzor/homelable-backend`](https://github.com/Pouzor/homelable/pkgs/container/homelable-backend) | FastAPI API, scanner, status checker |
|
||||
| [`ghcr.io/pouzor/homelable-frontend`](https://github.com/Pouzor/homelable/pkgs/container/homelable-frontend) | React SPA behind nginx, proxying `/api` to the backend |
|
||||
| [`ghcr.io/pouzor/homelable-frontend-standalone`](https://github.com/Pouzor/homelable/pkgs/container/homelable-frontend-standalone) | Same SPA built with `VITE_STANDALONE=true` — no backend, canvases in `localStorage` |
|
||||
| [`ghcr.io/pouzor/homelable-mcp`](https://github.com/Pouzor/homelable/pkgs/container/homelable-mcp) | MCP server exposing the canvas to AI clients |
|
||||
|
||||
Tags: `latest` (tip of `main`), plus `X.Y.Z` and `X.Y` for releases.
|
||||
|
||||
To wire it up by hand instead of using `install.sh`:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://raw.githubusercontent.com/Pouzor/homelable/main/docker-compose.prebuilt.yml
|
||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/.env.example -o .env
|
||||
docker compose -f docker-compose.prebuilt.yml up -d
|
||||
```
|
||||
|
||||
Update the same way: `docker compose -f docker-compose.prebuilt.yml pull && … up -d`.
|
||||
|
||||
## Build from source
|
||||
|
||||
`docker-compose.yml` at the repo root builds the images locally instead of pulling them — use it for development, or to run a patched tree.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Pouzor/homelable.git
|
||||
cd homelable
|
||||
|
||||
@@ -65,7 +65,7 @@ Every feature, with how to turn it on and use it, is described in **[FEATURES.md
|
||||
|
||||
## Installation
|
||||
|
||||
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||
Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.services.inventory_sync import (
|
||||
facts_from_payload,
|
||||
hydrated_node,
|
||||
link_facts,
|
||||
load_devices_for,
|
||||
node_columns,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -28,12 +35,15 @@ async def load_canvas(
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
nodes = list((await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all())
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
# The device facts live on the inventory row now; hydrate them back into the
|
||||
# node so the wire shape the canvas consumes is unchanged.
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
@@ -74,16 +84,41 @@ async def save_canvas(
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Upsert nodes
|
||||
# Upsert nodes, then route their device facts to the inventory row that owns
|
||||
# them — an edit made on this canvas is an edit to the device itself, and
|
||||
# every other canvas showing it follows.
|
||||
saved: list[tuple[Node, dict[str, Any], list[str] | None]] = []
|
||||
for node_data in body.nodes:
|
||||
db_node = await db.get(Node, node_data.id)
|
||||
payload = node_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
facts = facts_from_payload(payload, label=node_data.label, node_type=node_data.type)
|
||||
columns = node_columns(payload)
|
||||
if db_node:
|
||||
for field, value in payload.items():
|
||||
for field, value in columns.items():
|
||||
setattr(db_node, field, value)
|
||||
else:
|
||||
db.add(Node(**payload))
|
||||
db_node = Node(**columns)
|
||||
db.add(db_node)
|
||||
saved.append((db_node, facts, node_data.changed_facts))
|
||||
|
||||
await db.flush()
|
||||
for node, facts, changed in saved:
|
||||
# The payload carries a full copy of each device, hydrated when this
|
||||
# canvas loaded. Routing all of it back would rewrite the row from a
|
||||
# stale snapshot — a save made for nothing but a moved node could revert
|
||||
# an edit meanwhile in the inventory modal. `changed_facts` (when the
|
||||
# client tracked it) plus the row diff narrow the write to this canvas'
|
||||
# actual edit.
|
||||
await link_facts(
|
||||
db,
|
||||
node,
|
||||
facts,
|
||||
overwrite_scalars=True,
|
||||
replace_lists=True,
|
||||
only_changed=True,
|
||||
changed_fields=changed,
|
||||
)
|
||||
|
||||
# Upsert edges
|
||||
for edge_data in body.edges:
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.services.inventory_sync import hydrated_node, load_devices_for
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -59,13 +60,15 @@ async def liveview_canvas(
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
nodes = list((await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all())
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||
# Device facts come off the inventory row, same as the editable canvas.
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=custom_style,
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Design, Node
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
||||
from app.services.inventory_sync import (
|
||||
facts_from_payload,
|
||||
facts_from_update,
|
||||
hydrated_node,
|
||||
link_facts,
|
||||
load_devices_for,
|
||||
node_columns,
|
||||
)
|
||||
from app.services.node_dedupe import find_duplicate_node
|
||||
|
||||
router = APIRouter()
|
||||
@@ -59,15 +69,17 @@ async def list_nodes(
|
||||
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[Node]:
|
||||
) -> list[Any]:
|
||||
query = select(Node)
|
||||
if label:
|
||||
query = query.where(Node.label.ilike(f"%{label}%"))
|
||||
return list((await db.execute(query)).scalars().all())
|
||||
nodes = list((await db.execute(query)).scalars().all())
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return [hydrated_node(n, devices.get(n.device_id or "")) for n in nodes]
|
||||
|
||||
|
||||
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
|
||||
data = body.model_dump()
|
||||
# `force` bypasses the duplicate guard below; it is not a Node column.
|
||||
force = data.pop("force", False)
|
||||
@@ -103,33 +115,57 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
|
||||
if data["pos_y"] is None:
|
||||
data["pos_y"] = 0.0
|
||||
|
||||
node = Node(**data)
|
||||
node = Node(**node_columns(data))
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
# A new device node gets (or joins) its Device Inventory row — the canvas is
|
||||
# one more way to document hardware, not a parallel store.
|
||||
await link_facts(
|
||||
db, node, facts_from_payload(data, label=data["label"], node_type=data["type"])
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
return node
|
||||
return await _hydrate(db, node)
|
||||
|
||||
|
||||
async def _hydrate(db: AsyncSession, node: Node) -> dict[str, Any]:
|
||||
"""Node as the API reports it, with the device facts read off its row."""
|
||||
device = await db.get(InventoryDevice, node.device_id) if node.device_id else None
|
||||
return hydrated_node(node, device)
|
||||
|
||||
|
||||
@router.get("/{node_id}", response_model=NodeResponse)
|
||||
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
||||
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
|
||||
node = await db.get(Node, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
|
||||
return node
|
||||
return await _hydrate(db, node)
|
||||
|
||||
|
||||
@router.patch("/{node_id}", response_model=NodeResponse)
|
||||
async def update_node(
|
||||
node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> Node:
|
||||
) -> Any:
|
||||
node = await db.get(Node, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
sent = body.model_dump(exclude_unset=True)
|
||||
for field, value in node_columns(sent).items():
|
||||
setattr(node, field, value)
|
||||
# The user edited the device, not just its drawing: push the facts down.
|
||||
# Only what was sent counts — an omitted field must not clear the row, and
|
||||
# lists are replaced only when the client actually sent them.
|
||||
facts = facts_from_update(sent)
|
||||
await link_facts(
|
||||
db,
|
||||
node,
|
||||
facts,
|
||||
overwrite_scalars=True,
|
||||
replace_lists="properties" in sent or "services" in sent,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
return node
|
||||
return await _hydrate(db, node)
|
||||
|
||||
|
||||
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -35,7 +35,7 @@ from app.schemas.proxmox import (
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.proxmox_service import (
|
||||
build_proxmox_cluster_links,
|
||||
build_proxmox_properties,
|
||||
@@ -254,7 +254,7 @@ async def _persist_pending_import(
|
||||
|
||||
Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
|
||||
"""
|
||||
await dedupe_nodes_by_ieee(db)
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
cluster_pairs = build_proxmox_cluster_links(nodes_raw)
|
||||
cluster_members = {ieee for pair in cluster_pairs for ieee in pair}
|
||||
@@ -270,51 +270,44 @@ async def _persist_pending_import(
|
||||
mac = normalize_mac(n.get("mac"))
|
||||
props = build_proxmox_properties(n)
|
||||
|
||||
# 1) Already on a canvas? Match by ieee OR ip OR mac (the cross-source
|
||||
# dedup key — a stopped VM has no IP but its configured NIC MAC still
|
||||
# matches an ARP-scanned node). Refresh in place: merge properties, adopt
|
||||
# the pve identity onto a scanned node, backfill blank specs/hostname/mac.
|
||||
# Do NOT stomp user-set type/status.
|
||||
node_filter = [Node.ieee_address == ieee]
|
||||
if ip:
|
||||
node_filter.append(Node.ip == ip)
|
||||
if mac:
|
||||
node_filter.append(Node.mac == mac)
|
||||
existing_nodes = (
|
||||
await db.execute(select(Node).where(or_(*node_filter)).order_by(Node.id))
|
||||
).scalars().all()
|
||||
|
||||
if existing_nodes:
|
||||
for en in existing_nodes:
|
||||
en.properties = merge_proxmox_properties(en.properties, props)
|
||||
if not en.ieee_address:
|
||||
en.ieee_address = ieee
|
||||
if ip and not en.ip:
|
||||
en.ip = ip
|
||||
if mac and not en.mac:
|
||||
en.mac = mac
|
||||
en.hostname = en.hostname or n.get("hostname")
|
||||
en.cpu_count = en.cpu_count or n.get("cpu_count")
|
||||
en.ram_gb = en.ram_gb or n.get("ram_gb")
|
||||
en.disk_gb = en.disk_gb or n.get("disk_gb")
|
||||
# A cluster host needs one left + one right handle for the
|
||||
# cluster edge endpoints (both default to 0).
|
||||
if ieee in cluster_members:
|
||||
en.left_handles = max(en.left_handles or 0, 1)
|
||||
en.right_handles = max(en.right_handles or 0, 1)
|
||||
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
|
||||
pending_updated += 1
|
||||
continue
|
||||
|
||||
# 2) Not on canvas — upsert the pending inventory row.
|
||||
# Match by ieee OR ip OR mac (the cross-source dedup key — a stopped VM
|
||||
# has no IP but its configured NIC MAC still matches an ARP-scanned
|
||||
# device). The inventory row owns the facts, so the import merges into
|
||||
# it whether or not the device is drawn anywhere; a node only needs its
|
||||
# cluster handles adjusted. Do NOT stomp user-set type/status.
|
||||
pending = await _find_pending(db, ieee, ip, mac)
|
||||
drawn = bool(
|
||||
pending
|
||||
and (
|
||||
await db.execute(select(Node.id).where(Node.device_id == pending.id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
)
|
||||
|
||||
if pending is None:
|
||||
db.add(_new_pending(ieee, ip, mac, n, props, status="pending"))
|
||||
pending_created += 1
|
||||
else:
|
||||
_refresh_pending(pending, ieee, ip, mac, n, props)
|
||||
if drawn:
|
||||
# Already on a canvas: refresh the facts but leave the lifecycle
|
||||
# alone (_refresh_pending would revive it as pending).
|
||||
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
|
||||
else:
|
||||
_refresh_pending(pending, ieee, ip, mac, n, props)
|
||||
pending.cpu_count = pending.cpu_count or n.get("cpu_count")
|
||||
pending.ram_gb = pending.ram_gb or n.get("ram_gb")
|
||||
pending.disk_gb = pending.disk_gb or n.get("disk_gb")
|
||||
pending_updated += 1
|
||||
|
||||
# A cluster host needs one left + one right handle for the cluster
|
||||
# edge endpoints (both default to 0) — the one piece of this that is
|
||||
# genuinely about how the node is drawn.
|
||||
if ieee in cluster_members:
|
||||
for en in (
|
||||
await db.execute(select(Node).where(Node.device_id == pending.id))
|
||||
).scalars().all():
|
||||
en.left_handles = max(en.left_handles or 0, 1)
|
||||
en.right_handles = max(en.right_handles or 0, 1)
|
||||
|
||||
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
|
||||
await db.commit()
|
||||
|
||||
@@ -357,6 +350,11 @@ def _new_pending(
|
||||
vendor=n.get("vendor"),
|
||||
model=n.get("model"),
|
||||
properties=props,
|
||||
# Specs the guest reports. They used to land only on the canvas node;
|
||||
# the inventory row owns them now.
|
||||
cpu_count=n.get("cpu_count"),
|
||||
ram_gb=n.get("ram_gb"),
|
||||
disk_gb=n.get("disk_gb"),
|
||||
status=status,
|
||||
discovery_source=_PROXMOX_GUEST_SOURCE,
|
||||
discovery_sources=[_PROXMOX_GUEST_SOURCE],
|
||||
|
||||
@@ -286,37 +286,38 @@ async def rack_inventory(
|
||||
# to find anything in the first place.
|
||||
pinned = {device_id: node_id for device_id, node_id in mounts if device_id and node_id}
|
||||
|
||||
# Correlate against canvas nodes for live status: IEEE first (stable), then IP.
|
||||
# Correlate against canvas nodes: a node names the inventory row it draws,
|
||||
# so the IEEE/IP guessing this used to do is gone — the link is explicit.
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
by_id = {n.id: n for n in nodes}
|
||||
by_ieee = {n.ieee_address: n for n in nodes if n.ieee_address}
|
||||
by_ip: dict[str, Node] = {}
|
||||
by_device: dict[str, Node] = {}
|
||||
for node in nodes:
|
||||
for token in _ip_tokens(node.ip):
|
||||
by_ip.setdefault(token, node)
|
||||
if node.device_id:
|
||||
by_device.setdefault(node.device_id, node)
|
||||
# Which canvas the matched node lives on — the rack prints it so the user
|
||||
# knows where the logical twin of a mount is drawn.
|
||||
design_names = {
|
||||
d.id: d.name for d in (await db.execute(select(Design))).scalars().all()
|
||||
}
|
||||
|
||||
# A pinned node may draw a *different* device than the mount names, so its
|
||||
# view is read off its own row.
|
||||
devices_by_id = {d.id: d for d in devices}
|
||||
|
||||
items = []
|
||||
for device in devices:
|
||||
if device.suggested_type in _UNRACKABLE_TYPES:
|
||||
continue
|
||||
# A node the user linked by hand wins; a deleted one falls back to the
|
||||
# guess rather than reporting a node that no longer exists.
|
||||
linked: Node | None = by_id.get(pinned.get(device.id) or "")
|
||||
if linked is None and device.ieee_address:
|
||||
linked = by_ieee.get(device.ieee_address)
|
||||
if linked is None:
|
||||
linked = next((by_ip[t] for t in _ip_tokens(device.ip) if t in by_ip), None)
|
||||
# The node is the richer record once a device has been approved onto a
|
||||
# canvas; the inventory row is what discovery last saw. Report both and
|
||||
# let the panel prefer the node.
|
||||
services = _services(device.services) or (
|
||||
_services(linked.services) if linked else []
|
||||
)
|
||||
# node that draws this device, if any.
|
||||
linked: Node | None = by_id.get(pinned.get(device.id) or "") or by_device.get(device.id)
|
||||
# The logical view is whatever the linked node actually draws. Usually
|
||||
# that is this same row — the node_* fields then simply mirror it.
|
||||
node_device = None
|
||||
if linked is not None:
|
||||
node_device = devices_by_id.get(linked.device_id or "")
|
||||
if node_device is None and linked.device_id:
|
||||
node_device = await db.get(InventoryDevice, linked.device_id)
|
||||
items.append(
|
||||
RackInventoryItem(
|
||||
id=device.id,
|
||||
@@ -328,21 +329,25 @@ async def rack_inventory(
|
||||
mac=device.mac or device.ieee_address,
|
||||
hostname=device.hostname,
|
||||
os=device.os,
|
||||
services=services,
|
||||
services=_services(device.services),
|
||||
node_id=linked.id if linked else None,
|
||||
node_status=linked.status if linked else None,
|
||||
node_label=linked.label if linked else None,
|
||||
node_type=linked.type if linked else None,
|
||||
node_ip=linked.ip if linked else None,
|
||||
node_mac=(linked.mac or linked.ieee_address) if linked else None,
|
||||
node_hostname=linked.hostname if linked else None,
|
||||
node_os=linked.os if linked else None,
|
||||
node_check_method=linked.check_method if linked else None,
|
||||
node_status=node_device.status_live if node_device else None,
|
||||
node_label=(
|
||||
(node_device.label if node_device else None) or linked.label
|
||||
) if linked else None,
|
||||
node_type=(
|
||||
(node_device.type if node_device else None) or linked.type
|
||||
) if linked else None,
|
||||
node_ip=node_device.ip if node_device else None,
|
||||
node_mac=(node_device.mac or node_device.ieee_address) if node_device else None,
|
||||
node_hostname=node_device.hostname if node_device else None,
|
||||
node_os=node_device.os if node_device else None,
|
||||
node_check_method=node_device.check_method if node_device else None,
|
||||
node_design_id=linked.design_id if linked else None,
|
||||
node_design_name=(
|
||||
design_names.get(linked.design_id) if linked and linked.design_id else None
|
||||
),
|
||||
node_last_seen=linked.last_seen if linked else None,
|
||||
node_last_seen=node_device.last_seen if node_device else None,
|
||||
racked=device.id in mounted,
|
||||
)
|
||||
)
|
||||
|
||||
+183
-121
@@ -22,9 +22,16 @@ from app.db.models import (
|
||||
ScanRun,
|
||||
)
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import InventoryDeviceCreate, InventoryDeviceResponse, ScanRunResponse
|
||||
from app.schemas.scan import (
|
||||
InventoryDeviceCreate,
|
||||
InventoryDeviceResponse,
|
||||
InventoryDeviceUpdate,
|
||||
ScanRunResponse,
|
||||
)
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.inventory_sync import find_device_for, merge_properties, merge_services
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device, find_duplicate_node
|
||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
@@ -244,65 +251,38 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
||||
async def _canvas_correlation(
|
||||
db: AsyncSession, devices: list[InventoryDevice]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
|
||||
or ``ip``.
|
||||
"""Correlate each device to the canvas nodes drawing it.
|
||||
|
||||
Returns, per device id: the number of distinct canvases (designs) it appears
|
||||
on, plus aggregated timestamps from every matching node — created_at (oldest),
|
||||
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
|
||||
(node counts are small for a homelab), so no N+1 per device.
|
||||
on, plus aggregated node timestamps — created_at (oldest) and updated_at
|
||||
(newest). A node names the inventory row it draws, so this is a group-by on
|
||||
``device_id`` rather than the ieee/mac/ip guesswork it used to be.
|
||||
|
||||
IP matching is per-address: a node's ``ip`` may hold several comma-separated
|
||||
addresses (e.g. an IPv6 added before the IPv4), so we index each token, not
|
||||
the raw string. MAC is a stable identifier immune to such IP edits, so it is
|
||||
matched too — cumulatively with ieee/ip (issue #258).
|
||||
``last_scan`` and ``last_seen`` come off the device row itself now: they are
|
||||
observations of the device, not of any one drawing of it.
|
||||
"""
|
||||
if not devices:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Node.ip,
|
||||
Node.mac,
|
||||
Node.ieee_address,
|
||||
Node.design_id,
|
||||
Node.created_at,
|
||||
Node.last_scan,
|
||||
Node.updated_at,
|
||||
Node.last_seen,
|
||||
).where(Node.design_id.isnot(None))
|
||||
select(Node.device_id, Node.design_id, Node.created_at, Node.updated_at)
|
||||
.where(Node.design_id.isnot(None), Node.device_id.isnot(None))
|
||||
)
|
||||
).all()
|
||||
# Index matching nodes by ip token, mac and ieee so a device can look up any.
|
||||
by_ip: dict[str, list[Any]] = {}
|
||||
by_mac: dict[str, list[Any]] = {}
|
||||
by_ieee: dict[str, list[Any]] = {}
|
||||
by_device: dict[str, list[Any]] = {}
|
||||
for row in rows:
|
||||
for tok in _ip_tokens(row.ip):
|
||||
by_ip.setdefault(tok, []).append(row)
|
||||
if row.mac:
|
||||
by_mac.setdefault(row.mac, []).append(row)
|
||||
if row.ieee_address:
|
||||
by_ieee.setdefault(row.ieee_address, []).append(row)
|
||||
by_device.setdefault(row.device_id, []).append(row)
|
||||
|
||||
info: dict[str, dict[str, Any]] = {}
|
||||
for d in devices:
|
||||
matched = []
|
||||
if d.ieee_address:
|
||||
matched += by_ieee.get(d.ieee_address, [])
|
||||
if d.mac:
|
||||
matched += by_mac.get(d.mac, [])
|
||||
for tok in _ip_tokens(d.ip):
|
||||
matched += by_ip.get(tok, [])
|
||||
# De-duplicate nodes matched by more than one identifier.
|
||||
matched = list({id(m): m for m in matched}.values())
|
||||
matched = by_device.get(d.id, [])
|
||||
designs = {m.design_id for m in matched}
|
||||
info[d.id] = {
|
||||
"canvas_count": len(designs),
|
||||
"node_created_at": _agg([m.created_at for m in matched], newest=False),
|
||||
"node_last_scan": _agg([m.last_scan for m in matched], newest=True),
|
||||
"node_last_scan": d.last_scan,
|
||||
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
|
||||
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
|
||||
"node_last_seen": d.last_seen,
|
||||
}
|
||||
return info
|
||||
|
||||
@@ -341,13 +321,41 @@ async def create_pending(
|
||||
Lands as ``status="pending"`` like a discovery would, so the existing approve
|
||||
/ hide / restore flows apply unchanged.
|
||||
"""
|
||||
# One device is one row. If this host is already known — by ieee, ip or mac —
|
||||
# the user is documenting the device they already have, so fill in what the
|
||||
# row is missing rather than splitting it in two.
|
||||
mac = normalize_mac(body.mac)
|
||||
existing = await find_device_for(db, ip=body.ip, mac=mac, ieee=None)
|
||||
if existing is not None:
|
||||
for field in (
|
||||
"hostname", "ip", "os", "suggested_type", "model", "vendor", "label", "type",
|
||||
"notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb", "check_method",
|
||||
"check_target", "friendly_name", "device_subtype",
|
||||
):
|
||||
value = getattr(body, field, None)
|
||||
if value not in (None, "") and getattr(existing, field, None) in (None, ""):
|
||||
setattr(existing, field, value)
|
||||
existing.mac = existing.mac or mac
|
||||
existing.properties = merge_properties(existing.properties, body.properties)
|
||||
existing.services = merge_services(existing.services, body.services)
|
||||
existing.discovery_sources = add_source(existing.discovery_sources, body.discovery_source)
|
||||
# A hidden row is still that device, so it is the one to fill in — but
|
||||
# adding a device by hand is asking for it, and returning it still hidden
|
||||
# would leave the user with a 201 and nothing in the inventory. The
|
||||
# explicit add outranks the earlier hide, exactly like restore.
|
||||
if existing.status == "hidden":
|
||||
existing.status = "pending"
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return (await _with_canvas_counts(db, [existing]))[0]
|
||||
|
||||
device = InventoryDevice(
|
||||
hostname=body.hostname,
|
||||
ip=body.ip,
|
||||
# Canonical form, like every other write path: dedup compares MACs by
|
||||
# equality, so a hand-typed "AA-BB-CC-11-22-33" would never match the
|
||||
# scanned "aa:bb:cc:11:22:33" and approve would build a duplicate node.
|
||||
mac=normalize_mac(body.mac),
|
||||
mac=mac,
|
||||
suggested_type=body.suggested_type,
|
||||
model=body.model,
|
||||
vendor=body.vendor,
|
||||
@@ -355,6 +363,20 @@ async def create_pending(
|
||||
status="pending",
|
||||
discovery_source=body.discovery_source,
|
||||
discovery_sources=[body.discovery_source],
|
||||
os=body.os,
|
||||
services=body.services,
|
||||
friendly_name=body.friendly_name,
|
||||
device_subtype=body.device_subtype,
|
||||
label=body.label,
|
||||
type=body.type,
|
||||
notes=body.notes,
|
||||
cpu_count=body.cpu_count,
|
||||
cpu_model=body.cpu_model,
|
||||
ram_gb=body.ram_gb,
|
||||
disk_gb=body.disk_gb,
|
||||
show_hardware=body.show_hardware,
|
||||
check_method=body.check_method,
|
||||
check_target=body.check_target,
|
||||
)
|
||||
db.add(device)
|
||||
await db.commit()
|
||||
@@ -362,6 +384,37 @@ async def create_pending(
|
||||
return (await _with_canvas_counts(db, [device]))[0]
|
||||
|
||||
|
||||
@router.patch("/pending/{device_id}", response_model=InventoryDeviceResponse)
|
||||
async def update_pending(
|
||||
device_id: str,
|
||||
body: InventoryDeviceUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> InventoryDevice:
|
||||
"""Edit an inventory row — the device facts, not its lifecycle.
|
||||
|
||||
The inventory row owns what a device *is*; a canvas node only owns how it is
|
||||
drawn. This is the write path behind the device detail modal, and applies
|
||||
only the fields the client actually sent.
|
||||
"""
|
||||
device = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id == device_id))
|
||||
).scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "mac" in data:
|
||||
# Same reason as create_pending: dedup matches MACs by equality.
|
||||
data["mac"] = normalize_mac(data["mac"])
|
||||
for field, value in data.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(device)
|
||||
return (await _with_canvas_counts(db, [device]))[0]
|
||||
|
||||
|
||||
@router.delete("/pending", response_model=dict)
|
||||
async def clear_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -417,7 +470,7 @@ async def bulk_approve_devices(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Repair any legacy same-canvas duplicate nodes before placing more.
|
||||
await dedupe_nodes_by_ieee(db)
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
# Target the design the user is on; fall back to the first design.
|
||||
default_design_id = payload.design_id
|
||||
@@ -438,25 +491,18 @@ async def bulk_approve_devices(
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
|
||||
# What already sits on the target canvas, so we skip devices already placed
|
||||
# here (by ip, mac or ieee_address) instead of creating duplicate nodes. We
|
||||
# map to the existing node id so the skip report can point the user at it. A
|
||||
# value may be a Node still pending flush (in-batch duplicate) — resolved to
|
||||
# its id after the flush below. IPs are indexed per comma-separated token so
|
||||
# a node whose ip is "fe80::1, 192.168.1.5" still matches a device scanned as
|
||||
# 192.168.1.5 (issue #258).
|
||||
# What already sits on the target canvas. A node names the inventory row it
|
||||
# draws, so "already placed" is now a device-id lookup rather than a guess
|
||||
# across ip/mac/ieee. The value may be a Node still pending flush (in-batch
|
||||
# duplicate) — resolved to its id after the flush below.
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Node.id, Node.ip, Node.mac, Node.ieee_address).where(
|
||||
Node.design_id == default_design_id
|
||||
select(Node.id, Node.device_id).where(
|
||||
Node.design_id == default_design_id, Node.device_id.isnot(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
placed_ips: dict[str, Any] = {
|
||||
tok: nid for nid, ip, _, _ in existing for tok in _ip_tokens(ip)
|
||||
}
|
||||
placed_mac: dict[str, Any] = {mac: nid for nid, _, mac, _ in existing if mac}
|
||||
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
|
||||
placed: dict[str, Any] = {device_id: nid for nid, device_id in existing}
|
||||
|
||||
created_nodes: list[Node] = []
|
||||
approved_devices: list[InventoryDevice] = []
|
||||
@@ -470,66 +516,58 @@ async def bulk_approve_devices(
|
||||
"match": "rack", "value": "rack device", "_ref": None,
|
||||
})
|
||||
continue
|
||||
# Record which identifier collided so the caller can explain each skip
|
||||
# (and, for existing on-canvas nodes, link to the node already there).
|
||||
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
|
||||
if ip_hit is not None:
|
||||
# Report which identifier names the device, so the caller can explain
|
||||
# the skip and link to the node already drawing it.
|
||||
if device.id in placed:
|
||||
match, value = (
|
||||
("ip", device.ip) if device.ip
|
||||
else ("ieee", device.ieee_address) if device.ieee_address
|
||||
else ("mac", device.mac)
|
||||
)
|
||||
skipped_devices.append({
|
||||
"device_id": device.id,
|
||||
"label": device.hostname or device.friendly_name or device.ip or "device",
|
||||
"match": "ip", "value": ip_hit, "_ref": placed_ips[ip_hit],
|
||||
})
|
||||
continue
|
||||
if device.ieee_address is not None and device.ieee_address in placed_ieee:
|
||||
skipped_devices.append({
|
||||
"device_id": device.id,
|
||||
"label": device.hostname or device.friendly_name or device.ieee_address or "device",
|
||||
"match": "ieee", "value": device.ieee_address, "_ref": placed_ieee[device.ieee_address],
|
||||
})
|
||||
continue
|
||||
if device.mac is not None and device.mac in placed_mac:
|
||||
skipped_devices.append({
|
||||
"device_id": device.id,
|
||||
"label": device.hostname or device.friendly_name or device.mac or "device",
|
||||
"match": "mac", "value": device.mac, "_ref": placed_mac[device.mac],
|
||||
"label": device.hostname or device.friendly_name or value or "device",
|
||||
"match": match, "value": value, "_ref": placed[device.id],
|
||||
})
|
||||
continue
|
||||
device.status = "approved"
|
||||
node_type = device.suggested_type or "generic"
|
||||
is_wireless = _is_wireless(node_type)
|
||||
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
||||
# Enrich the row, not the node: a mesh device's radio properties and the
|
||||
# default check method describe the device itself.
|
||||
device.type = device.type or node_type
|
||||
device.label = device.label or device.hostname or device.friendly_name or device.ip or "device"
|
||||
device.properties = (
|
||||
_wireless_properties(node_type, device.ieee_address, device.vendor, device.model, device.lqi)
|
||||
if is_wireless
|
||||
else merge_mac_property(list(device.properties or []), device.mac)
|
||||
)
|
||||
if is_wireless:
|
||||
# A mesh device answers no ICMP; being in the mesh is the liveness.
|
||||
device.check_method = "none"
|
||||
device.status_live = "online"
|
||||
elif not device.check_method:
|
||||
# Default to ping so the status checker actually polls it. Without
|
||||
# this the scheduler skips it (check_method NULL -> no check).
|
||||
device.check_method = "ping" if device.ip else None
|
||||
node = Node(
|
||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||
type=node_type,
|
||||
ip=device.ip,
|
||||
mac=device.mac,
|
||||
hostname=device.hostname,
|
||||
status="online" if is_wireless else "unknown",
|
||||
services=device.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=_wireless_properties(
|
||||
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if is_wireless else merge_mac_property(list(device.properties or []), device.mac),
|
||||
# Default to ping so the status checker actually polls the new node.
|
||||
# Without this the scheduler skips it (check_method NULL → no check).
|
||||
check_method="none" if is_wireless else ("ping" if device.ip else None),
|
||||
# Cluster hosts get side handles for their host↔host cluster edge.
|
||||
label=device.label,
|
||||
type=device.type,
|
||||
# Cluster hosts get side handles for their host<->host cluster edge.
|
||||
left_handles=1 if cluster_host else 0,
|
||||
right_handles=1 if cluster_host else 0,
|
||||
design_id=default_design_id,
|
||||
# The node draws this inventory row; the row owns the facts.
|
||||
device_id=device.id,
|
||||
)
|
||||
db.add(node)
|
||||
created_nodes.append(node)
|
||||
approved_devices.append(device)
|
||||
# Track within this batch so a duplicate selection (same ip/mac/ieee) is
|
||||
# not placed twice on the same canvas. Store the Node so a later in-batch
|
||||
# skip can resolve to its id after flush.
|
||||
for tok in _ip_tokens(device.ip):
|
||||
placed_ips[tok] = node
|
||||
if device.mac:
|
||||
placed_mac[device.mac] = node
|
||||
if device.ieee_address:
|
||||
placed_ieee[device.ieee_address] = node
|
||||
# Track within this batch so a duplicate selection is not placed twice
|
||||
# on the same canvas. Stores the Node so a later in-batch skip can
|
||||
# resolve to its id after flush.
|
||||
placed[device.id] = node
|
||||
await db.flush() # populates node.id from Python-side default before reading
|
||||
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
|
||||
node_ids = [n.id for n in created_nodes]
|
||||
@@ -663,31 +701,52 @@ async def approve_device(
|
||||
raise HTTPException(status_code=409, detail=conflict)
|
||||
|
||||
device.status = "approved"
|
||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||
# Prefer the MAC discovered during the scan (stored on the inventory row);
|
||||
# fall back to whatever the approve payload carried.
|
||||
_mac = device.mac or node_data.mac
|
||||
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
||||
node = Node(
|
||||
label=node_data.label,
|
||||
type=node_data.type,
|
||||
ip=node_data.ip,
|
||||
mac=_mac,
|
||||
hostname=node_data.hostname,
|
||||
status="online" if wireless else node_data.status,
|
||||
services=node_data.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=_wireless_properties(
|
||||
|
||||
# The approve dialog is an edit of the device, so its values land on the row
|
||||
# — the node that follows only says where it is drawn. A blank field in the
|
||||
# payload never clears what discovery already found.
|
||||
device.label = node_data.label or device.label
|
||||
device.type = node_data.type or device.type
|
||||
device.ip = node_data.ip or device.ip
|
||||
device.mac = _mac
|
||||
device.hostname = node_data.hostname or device.hostname
|
||||
device.services = merge_services(device.services, node_data.services)
|
||||
device.properties = (
|
||||
_wireless_properties(
|
||||
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if wireless else merge_mac_property(
|
||||
)
|
||||
if wireless
|
||||
else merge_mac_property(
|
||||
merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
|
||||
_mac,
|
||||
),
|
||||
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||
check_target=None if wireless else node_data.check_target,
|
||||
# Cluster hosts get side handles for their host↔host cluster edge.
|
||||
)
|
||||
)
|
||||
if wireless:
|
||||
# A mesh device answers no ICMP; being in the mesh is the liveness.
|
||||
device.check_method = "none"
|
||||
device.check_target = None
|
||||
device.status_live = "online"
|
||||
else:
|
||||
device.check_method = node_data.check_method or device.check_method or (
|
||||
"ping" if device.ip else None
|
||||
)
|
||||
device.check_target = node_data.check_target or device.check_target
|
||||
if node_data.status:
|
||||
device.status_live = node_data.status
|
||||
|
||||
node = Node(
|
||||
label=device.label or node_data.label,
|
||||
type=device.type or node_data.type,
|
||||
# Cluster hosts get side handles for their host<->host cluster edge.
|
||||
left_handles=1 if cluster_host else 0,
|
||||
right_handles=1 if cluster_host else 0,
|
||||
design_id=node_design_id,
|
||||
# The node draws this inventory row; the row owns the facts.
|
||||
device_id=device.id,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
@@ -760,13 +819,16 @@ async def _resolve_pending_links_for_ieee(
|
||||
for link in links
|
||||
}
|
||||
other_ieees.add(ieee)
|
||||
# A node reaches its ieee through the inventory row it draws.
|
||||
nodes_q = await db.execute(
|
||||
select(Node).where(
|
||||
Node.ieee_address.in_(other_ieees),
|
||||
select(InventoryDevice.ieee_address, Node)
|
||||
.join(Node, Node.device_id == InventoryDevice.id)
|
||||
.where(
|
||||
InventoryDevice.ieee_address.in_(other_ieees),
|
||||
Node.design_id == design_id,
|
||||
)
|
||||
)
|
||||
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
|
||||
by_ieee = {row_ieee: node for row_ieee, node in nodes_q.all() if row_ieee}
|
||||
|
||||
self_node = by_ieee.get(ieee)
|
||||
if self_node is None:
|
||||
|
||||
@@ -4,13 +4,19 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||
from app.core.scheduler import (
|
||||
reschedule_service_checks,
|
||||
reschedule_status_checks,
|
||||
set_service_checks_enabled,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
interval_seconds: int
|
||||
# Mirrors the floor enforced by reschedule_status_checks(): rejecting here
|
||||
# returns 422 instead of persisting the value and then failing with a 500.
|
||||
interval_seconds: int = Field(ge=10)
|
||||
service_check_enabled: bool = False
|
||||
service_check_interval: int = Field(default=300, ge=30)
|
||||
|
||||
@@ -33,6 +39,10 @@ async def update_settings(
|
||||
settings.service_check_enabled = payload.service_check_enabled
|
||||
settings.service_check_interval = payload.service_check_interval
|
||||
settings.save_overrides()
|
||||
# Apply the status-check schedule live. Without this the new interval is
|
||||
# persisted and shown in the UI, but the running job keeps the old one
|
||||
# until the backend restarts.
|
||||
reschedule_status_checks(payload.interval_seconds)
|
||||
# Apply the service-check schedule live.
|
||||
set_service_checks_enabled(payload.service_check_enabled)
|
||||
if payload.service_check_enabled:
|
||||
|
||||
@@ -30,10 +30,23 @@ async def summary(
|
||||
"""
|
||||
_check_key(x_api_key)
|
||||
|
||||
# A node's reachability lives on the Device Inventory row it draws, so the
|
||||
# per-canvas counts come from the join. A node with no row (canvas
|
||||
# furniture) counts as unknown.
|
||||
status_rows = (
|
||||
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
||||
await db.execute(
|
||||
select(InventoryDevice.status_live, func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.group_by(InventoryDevice.status_live)
|
||||
)
|
||||
).all()
|
||||
counts = {row[0]: row[1] for row in status_rows}
|
||||
counts = {(row[0] or "unknown"): row[1] for row in status_rows}
|
||||
unlinked = (
|
||||
await db.execute(select(func.count()).select_from(Node).where(Node.device_id.is_(None)))
|
||||
).scalar_one()
|
||||
if unlinked:
|
||||
counts["unknown"] = counts.get("unknown", 0) + unlinked
|
||||
|
||||
pending = (
|
||||
await db.execute(
|
||||
@@ -45,7 +58,10 @@ async def summary(
|
||||
|
||||
zigbee = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
|
||||
select(func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.where(InventoryDevice.ieee_address.isnot(None))
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
@@ -71,20 +71,36 @@ async def _broadcast(payload: str) -> None:
|
||||
_drop(conn)
|
||||
|
||||
|
||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
||||
async def broadcast_status(
|
||||
device_id: str,
|
||||
node_ids: list[str],
|
||||
status: str,
|
||||
checked_at: str,
|
||||
response_time_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Report one device's reachability to every canvas drawing it.
|
||||
|
||||
A device is checked once, not once per canvas, so the result is addressed by
|
||||
device and carries the nodes it lights up. ``node_ids`` is empty for a
|
||||
device that is in the inventory but on no canvas.
|
||||
"""
|
||||
await _broadcast(json.dumps({
|
||||
"type": "status",
|
||||
"node_id": node_id,
|
||||
"device_id": device_id,
|
||||
"node_ids": node_ids,
|
||||
"status": status,
|
||||
"checked_at": checked_at,
|
||||
"response_time_ms": response_time_ms,
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
||||
async def broadcast_service_status(
|
||||
device_id: str, node_ids: list[str], services: list[dict[str, object]], checked_at: str
|
||||
) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "service_status",
|
||||
"node_id": node_id,
|
||||
"device_id": device_id,
|
||||
"node_ids": node_ids,
|
||||
"services": services,
|
||||
"checked_at": checked_at,
|
||||
}))
|
||||
|
||||
@@ -27,7 +27,7 @@ from app.schemas.zigbee import (
|
||||
ZigbeeTestConnectionRequest,
|
||||
ZigbeeTestConnectionResponse,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
fetch_networkmap,
|
||||
@@ -36,6 +36,15 @@ from app.services.zigbee_service import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
|
||||
"""True while at least one canvas node still draws this device."""
|
||||
return (
|
||||
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none() is not None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -192,7 +201,7 @@ async def _persist_pending_import(
|
||||
"""
|
||||
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
|
||||
# the by-IEEE lookups below resolve to a single row.
|
||||
await dedupe_nodes_by_ieee(db)
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
# Coordinator is no longer auto-placed, so the response's coordinator fields
|
||||
# stay unset — retained for backward-compatible response shape.
|
||||
@@ -213,55 +222,10 @@ async def _persist_pending_import(
|
||||
# the pending inventory like every other device, so the user approves it
|
||||
# explicitly. Only the shared paths below run for it.
|
||||
|
||||
# If the device has already been approved as a canvas Node, refresh its
|
||||
# properties on every canvas it sits on. Still ensure the discovery
|
||||
# inventory carries a row for it (status="approved") so it shows in the
|
||||
# inventory list with an "In N canvas" badge — legacy auto-placed
|
||||
# coordinators never got a pending row, which is why they went missing.
|
||||
existing_nodes = (
|
||||
await db.execute(
|
||||
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
if existing_nodes:
|
||||
for existing_node in existing_nodes:
|
||||
existing_node.properties = merge_zigbee_properties(
|
||||
existing_node.properties, props
|
||||
)
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if inv is None:
|
||||
db.add(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
suggested_type=n.get("type"),
|
||||
device_subtype=n.get("device_type"),
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
status="approved",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
pending_created += 1
|
||||
else:
|
||||
# Refresh metadata but never change the row's status (an approved
|
||||
# device stays approved; a hidden one stays hidden).
|
||||
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||
inv.model = n.get("model") or inv.model
|
||||
inv.vendor = n.get("vendor") or inv.vendor
|
||||
if n.get("lqi") is not None:
|
||||
inv.lqi = n.get("lqi")
|
||||
pending_updated += 1
|
||||
continue
|
||||
|
||||
# Properties belong to the inventory row now, so one upsert serves
|
||||
# every canvas drawing this device — there is no per-node refresh left
|
||||
# to do. The row's status is never touched here: an approved device
|
||||
# stays approved, a hidden one stays hidden.
|
||||
result = await db.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
@@ -277,6 +241,7 @@ async def _persist_pending_import(
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
properties=props,
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
@@ -290,11 +255,11 @@ async def _persist_pending_import(
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if n.get("lqi") is not None:
|
||||
pending.lqi = n.get("lqi")
|
||||
if pending.status == "approved":
|
||||
# The device was approved earlier but its canvas Node no longer
|
||||
# exists (no Node matched the IEEE above) — it was deleted. Revive
|
||||
# the row to "pending" so it reappears in the Pending list on
|
||||
# re-import instead of being silently swallowed. (Issue #167)
|
||||
pending.properties = merge_zigbee_properties(list(pending.properties or []), props)
|
||||
if pending.status == "approved" and not await _is_drawn(db, pending.id):
|
||||
# Approved earlier but no canvas draws it any more — the node was
|
||||
# deleted. Revive the row to "pending" so it reappears in the list
|
||||
# on re-import instead of being silently swallowed. (Issue #167)
|
||||
pending.status = "pending"
|
||||
elif pending.status == "hidden":
|
||||
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
||||
|
||||
@@ -27,7 +27,7 @@ from app.schemas.zwave import (
|
||||
ZwaveTestConnectionRequest,
|
||||
ZwaveTestConnectionResponse,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.zwave_service import (
|
||||
build_zwave_properties,
|
||||
fetch_zwave_network,
|
||||
@@ -36,6 +36,15 @@ from app.services.zwave_service import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
|
||||
"""True while at least one canvas node still draws this device."""
|
||||
return (
|
||||
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none() is not None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -189,7 +198,7 @@ async def _persist_pending_import(
|
||||
"""
|
||||
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
|
||||
# the by-IEEE lookups below resolve cleanly.
|
||||
await dedupe_nodes_by_ieee(db)
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
# Coordinator is no longer auto-placed, so the response's coordinator fields
|
||||
# stay unset — retained for backward-compatible response shape.
|
||||
@@ -208,54 +217,10 @@ async def _persist_pending_import(
|
||||
# the pending inventory like every other device, so the user approves it
|
||||
# explicitly. Only the shared paths below run for it.
|
||||
|
||||
# Already approved as a canvas Node → refresh props on every canvas it
|
||||
# sits on. Still ensure the discovery inventory carries a row for it
|
||||
# (status="approved") so it shows in the inventory list with an
|
||||
# "In N canvas" badge — legacy auto-placed coordinators never got a
|
||||
# pending row, which is why they went missing.
|
||||
existing_nodes = (
|
||||
await db.execute(
|
||||
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
if existing_nodes:
|
||||
for existing_node in existing_nodes:
|
||||
existing_node.properties = merge_zwave_properties(
|
||||
existing_node.properties, props
|
||||
)
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if inv is None:
|
||||
db.add(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
suggested_type=n.get("type"),
|
||||
device_subtype=n.get("device_type"),
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
status="approved",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
)
|
||||
pending_created += 1
|
||||
else:
|
||||
# Refresh metadata but never change the row's status.
|
||||
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||
inv.model = n.get("model") or inv.model
|
||||
inv.vendor = n.get("vendor") or inv.vendor
|
||||
if n.get("lqi") is not None:
|
||||
inv.lqi = n.get("lqi")
|
||||
pending_updated += 1
|
||||
continue
|
||||
|
||||
# Properties belong to the inventory row now, so one upsert serves
|
||||
# every canvas drawing this device — there is no per-node refresh left
|
||||
# to do. The row's status is never touched here: an approved device
|
||||
# stays approved, a hidden one stays hidden.
|
||||
result = await db.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
@@ -271,6 +236,7 @@ async def _persist_pending_import(
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
properties=props,
|
||||
status="pending",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
@@ -282,9 +248,10 @@ async def _persist_pending_import(
|
||||
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
||||
pending.model = n.get("model") or pending.model
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if pending.status == "approved":
|
||||
# Approved earlier but the canvas Node is gone (deleted) — revive
|
||||
# to "pending" so it reappears in the list instead of vanishing.
|
||||
pending.properties = merge_zwave_properties(list(pending.properties or []), props)
|
||||
if pending.status == "approved" and not await _is_drawn(db, pending.id):
|
||||
# Approved earlier but no canvas draws it any more (the node was
|
||||
# deleted) — revive to "pending" so it reappears in the list.
|
||||
pending.status = "pending"
|
||||
elif pending.status == "hidden":
|
||||
pass
|
||||
|
||||
@@ -7,10 +7,11 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal
|
||||
from app.db.models import Node
|
||||
from app.db.models import InventoryDevice, Node
|
||||
from app.services.status_checker import check_node, check_services
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,13 +23,23 @@ logger = logging.getLogger(__name__)
|
||||
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def _check_single_node(
|
||||
node_id: str,
|
||||
async def _nodes_by_device(db: AsyncSession) -> dict[str, list[str]]:
|
||||
"""Map each device id to the nodes drawing it, across every canvas."""
|
||||
rows = (await db.execute(select(Node.id, Node.device_id).where(Node.device_id.is_not(None)))).all()
|
||||
out: dict[str, list[str]] = {}
|
||||
for node_id, device_id in rows:
|
||||
out.setdefault(device_id, []).append(node_id)
|
||||
return out
|
||||
|
||||
|
||||
async def _check_single_device(
|
||||
device_id: str,
|
||||
node_ids: list[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).
|
||||
"""Run a single device check; returns (device_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.
|
||||
@@ -37,45 +48,67 @@ async def _check_single_node(
|
||||
|
||||
try:
|
||||
check_result = await check_node(check_method, check_target, ip)
|
||||
raw_ms = check_result["response_time_ms"]
|
||||
response_ms = raw_ms if isinstance(raw_ms, int) else None
|
||||
now = datetime.now(timezone.utc)
|
||||
async with AsyncSessionLocal() as db:
|
||||
n = await db.get(Node, node_id)
|
||||
if n:
|
||||
n.status = check_result["status"]
|
||||
n.response_time_ms = check_result["response_time_ms"]
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device:
|
||||
device.status_live = str(check_result["status"])
|
||||
device.response_time_ms = response_ms
|
||||
if check_result["status"] == "online":
|
||||
n.last_seen = now
|
||||
device.last_seen = now
|
||||
await db.commit()
|
||||
await broadcast_status(
|
||||
node_id=node_id,
|
||||
status=check_result["status"],
|
||||
device_id=device_id,
|
||||
node_ids=node_ids,
|
||||
status=str(check_result["status"]),
|
||||
checked_at=now.isoformat(),
|
||||
response_time_ms=check_result["response_time_ms"],
|
||||
response_time_ms=response_ms,
|
||||
)
|
||||
return node_id, check_result
|
||||
return device_id, check_result
|
||||
except Exception as exc:
|
||||
logger.error("Status check failed for node %s: %s", node_id, exc)
|
||||
return node_id, None
|
||||
logger.error("Status check failed for device %s: %s", device_id, exc)
|
||||
return device_id, None
|
||||
|
||||
|
||||
async def _run_status_checks() -> None:
|
||||
"""Check all nodes concurrently and broadcast results via WebSocket."""
|
||||
"""Check every device once and broadcast the result to every canvas.
|
||||
|
||||
Device-scoped, not node-scoped: a host drawn on three canvases used to be
|
||||
pinged three times and could report three different states. Hidden devices
|
||||
are skipped — hiding one is how a user says "stop showing me this".
|
||||
|
||||
A device with no node at all is still checked, deliberately. The inventory
|
||||
row owns the device and shows its live status in the list and the detail
|
||||
modal, so a rack mount or an inventory-only entry reports state without
|
||||
being drawn anywhere — and deleting a node no longer silently stops
|
||||
monitoring the host. `hide` (or clearing the check method) is what ends the
|
||||
checks; the broadcast simply carries an empty `node_ids`.
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
devices = (
|
||||
await db.execute(
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.check_method.is_not(None),
|
||||
InventoryDevice.check_method != "",
|
||||
InventoryDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
node_map = await _nodes_by_device(db)
|
||||
# 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
|
||||
(d.id, node_map.get(d.id, []), d.check_method or "ping", d.check_target, d.ip)
|
||||
for d in devices
|
||||
]
|
||||
|
||||
if not checkable:
|
||||
return
|
||||
|
||||
await asyncio.gather(*[
|
||||
_check_single_node(node_id, method, target, ip)
|
||||
for node_id, method, target, ip in checkable
|
||||
_check_single_device(device_id, node_ids, method, target, ip)
|
||||
for device_id, node_ids, method, target, ip in checkable
|
||||
])
|
||||
|
||||
|
||||
@@ -89,27 +122,35 @@ def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||
|
||||
|
||||
async def _run_service_checks() -> None:
|
||||
"""Check every service of every node and broadcast per-service results."""
|
||||
"""Check every service of every device and broadcast per-service results.
|
||||
|
||||
Device-scoped for the same reason as the status check: the services belong
|
||||
to the device, so one pass serves every canvas showing it.
|
||||
"""
|
||||
if not settings.service_check_enabled:
|
||||
return
|
||||
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
devices = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
).scalars().all()
|
||||
node_map = await _nodes_by_device(db)
|
||||
checkable = [
|
||||
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||
for n in nodes
|
||||
if n.services
|
||||
(d.id, node_map.get(d.id, []), _node_host(d.ip, d.hostname), list(d.services or []))
|
||||
for d in devices
|
||||
if d.services
|
||||
]
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for node_id, host, services in checkable:
|
||||
for device_id, node_ids, host, services in checkable:
|
||||
try:
|
||||
statuses = await check_services(host, services)
|
||||
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||
await broadcast_service_status(
|
||||
device_id=device_id, node_ids=node_ids, services=statuses, checked_at=now
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
||||
logger.error("Service checks failed for device %s: %s", device_id, exc)
|
||||
|
||||
|
||||
async def _run_proxmox_sync() -> None:
|
||||
|
||||
@@ -204,6 +204,11 @@
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Pp]i[Kk]VM", "service_name": "PiKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "TinyPilot", "service_name": "TinyPilot", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "JetKVM", "service_name": "JetKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "NanoKVM", "service_name": "NanoKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
|
||||
|
||||
+224
-1
@@ -5,8 +5,9 @@ import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -426,6 +427,228 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
||||
)
|
||||
# 3.3.0 — the inventory row owns the device facts, the canvas node only
|
||||
# owns how it is drawn. These columns receive what used to live solely on
|
||||
# `nodes`. Additive here; `nodes` keeps its copies until the backfill has
|
||||
# run and been verified.
|
||||
inventory_device_migrations: list[tuple[str, str]] = [
|
||||
("device_inventory.label", "ALTER TABLE device_inventory ADD COLUMN label TEXT"),
|
||||
("device_inventory.type", "ALTER TABLE device_inventory ADD COLUMN type TEXT"),
|
||||
("device_inventory.notes", "ALTER TABLE device_inventory ADD COLUMN notes TEXT"),
|
||||
("device_inventory.cpu_count", "ALTER TABLE device_inventory ADD COLUMN cpu_count INTEGER"),
|
||||
("device_inventory.cpu_model", "ALTER TABLE device_inventory ADD COLUMN cpu_model TEXT"),
|
||||
("device_inventory.ram_gb", "ALTER TABLE device_inventory ADD COLUMN ram_gb FLOAT"),
|
||||
("device_inventory.disk_gb", "ALTER TABLE device_inventory ADD COLUMN disk_gb FLOAT"),
|
||||
(
|
||||
"device_inventory.show_hardware",
|
||||
"ALTER TABLE device_inventory ADD COLUMN show_hardware BOOLEAN DEFAULT 0",
|
||||
),
|
||||
("device_inventory.check_method", "ALTER TABLE device_inventory ADD COLUMN check_method TEXT"),
|
||||
("device_inventory.check_target", "ALTER TABLE device_inventory ADD COLUMN check_target TEXT"),
|
||||
(
|
||||
"device_inventory.status_live",
|
||||
"ALTER TABLE device_inventory ADD COLUMN status_live TEXT DEFAULT 'unknown'",
|
||||
),
|
||||
("device_inventory.last_seen", "ALTER TABLE device_inventory ADD COLUMN last_seen DATETIME"),
|
||||
("device_inventory.last_scan", "ALTER TABLE device_inventory ADD COLUMN last_scan DATETIME"),
|
||||
(
|
||||
"device_inventory.response_time_ms",
|
||||
"ALTER TABLE device_inventory ADD COLUMN response_time_ms INTEGER",
|
||||
),
|
||||
("device_inventory.updated_at", "ALTER TABLE device_inventory ADD COLUMN updated_at DATETIME"),
|
||||
]
|
||||
for label, sql in inventory_device_migrations:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
# Backfill the columns that carry a non-NULL default in the model, so a
|
||||
# legacy row round-trips through Pydantic without tripping the validators.
|
||||
# The link itself: a node points at the inventory row it draws.
|
||||
for label, sql in (
|
||||
("nodes.device_id", "ALTER TABLE nodes ADD COLUMN device_id TEXT"),
|
||||
(
|
||||
"nodes.device_id.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)",
|
||||
),
|
||||
):
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
for label, sql in (
|
||||
(
|
||||
"device_inventory.status_live.backfill",
|
||||
"UPDATE device_inventory SET status_live = 'unknown' WHERE status_live IS NULL",
|
||||
),
|
||||
(
|
||||
"device_inventory.show_hardware.backfill",
|
||||
"UPDATE device_inventory SET show_hardware = 0 WHERE show_hardware IS NULL",
|
||||
),
|
||||
(
|
||||
"device_inventory.updated_at.backfill",
|
||||
"UPDATE device_inventory SET updated_at = discovered_at WHERE updated_at IS NULL",
|
||||
),
|
||||
):
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
|
||||
await _backfill_node_devices()
|
||||
await _drop_legacy_node_columns()
|
||||
|
||||
|
||||
|
||||
# Columns `nodes` carried before 3.3.0, when a node owned the device facts. They
|
||||
# belong to `device_inventory` now; the backfill above copies them across, and
|
||||
# this rebuild removes them.
|
||||
_LEGACY_NODE_COLUMNS = (
|
||||
"hostname", "ip", "mac", "os", "status", "check_method", "check_target",
|
||||
"services", "notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb",
|
||||
"show_hardware", "properties", "ieee_address", "last_seen", "last_scan",
|
||||
"response_time_ms",
|
||||
)
|
||||
|
||||
# What a node keeps: how the device is drawn on one canvas.
|
||||
_NODE_COLUMNS_SQL = (
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"type VARCHAR NOT NULL,"
|
||||
"label VARCHAR NOT NULL,"
|
||||
"design_id VARCHAR REFERENCES designs(id) ON DELETE SET NULL,"
|
||||
"device_id VARCHAR REFERENCES device_inventory(id) ON DELETE SET NULL,"
|
||||
"pos_x FLOAT,"
|
||||
"pos_y FLOAT,"
|
||||
"parent_id VARCHAR REFERENCES nodes(id) ON DELETE CASCADE,"
|
||||
"container_mode BOOLEAN,"
|
||||
"custom_colors JSON,"
|
||||
"custom_icon VARCHAR,"
|
||||
"show_port_numbers BOOLEAN,"
|
||||
"width FLOAT,"
|
||||
"height FLOAT,"
|
||||
"bottom_handles INTEGER,"
|
||||
"top_handles INTEGER,"
|
||||
"left_handles INTEGER,"
|
||||
"right_handles INTEGER,"
|
||||
"created_at DATETIME,"
|
||||
"updated_at DATETIME"
|
||||
)
|
||||
|
||||
_NODE_KEPT = (
|
||||
"id, type, label, design_id, device_id, pos_x, pos_y, parent_id, container_mode, "
|
||||
"custom_colors, custom_icon, show_port_numbers, width, height, bottom_handles, "
|
||||
"top_handles, left_handles, right_handles, created_at, updated_at"
|
||||
)
|
||||
|
||||
|
||||
async def _relax_legacy_node_columns(conn: AsyncConnection, info: list[Any]) -> None:
|
||||
"""Make the retained legacy `nodes` columns nullable — SQLite table rebuild.
|
||||
|
||||
The 3.2.0 schema declares `status`, `services`, `properties` and
|
||||
`show_hardware` NOT NULL with no server-side default. The 3.3.0 model no
|
||||
longer maps them, so every INSERT omits them and SQLite rejects the row —
|
||||
approving a device, creating a node, importing a canvas all fail with
|
||||
``NOT NULL constraint failed: nodes.status``. Dropping the columns is the
|
||||
real fix, but it waits on a complete backfill; until then they have to stop
|
||||
blocking writes. Values are preserved: only the constraint goes.
|
||||
"""
|
||||
kept = [row for row in info if row[1] in _LEGACY_NODE_COLUMNS]
|
||||
if not any(row[3] for row in kept): # PRAGMA `notnull`
|
||||
return # Already relaxed, or never constrained.
|
||||
|
||||
logger.info("Relaxing NOT NULL on the retained legacy node columns")
|
||||
legacy_defs = ",".join(f"{row[1]} {row[2] or 'VARCHAR'}" for row in kept)
|
||||
legacy_names = ", ".join(row[1] for row in kept)
|
||||
await _rebuild_nodes(
|
||||
conn,
|
||||
columns_sql=f"{_NODE_COLUMNS_SQL},{legacy_defs}",
|
||||
copied=f"{_NODE_KEPT}, {legacy_names}",
|
||||
what="nodes legacy-column relax",
|
||||
)
|
||||
|
||||
|
||||
async def _rebuild_nodes(conn: AsyncConnection, *, columns_sql: str, copied: str, what: str) -> None:
|
||||
"""Recreate `nodes` with a new column list — SQLite cannot alter constraints.
|
||||
|
||||
Never fatal: a rebuild that fails leaves the table it could not replace, and
|
||||
the boot carries on. Foreign keys go off for the swap, because `edges` and
|
||||
`rack_devices` point at `nodes`, and back on in every case — the pragma is
|
||||
per connection and this one returns to the pool.
|
||||
"""
|
||||
try:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
# A previous attempt that failed after the create would block this one.
|
||||
await conn.exec_driver_sql("DROP TABLE IF EXISTS nodes_new")
|
||||
await conn.exec_driver_sql(f"CREATE TABLE nodes_new ({columns_sql})")
|
||||
await conn.exec_driver_sql(f"INSERT INTO nodes_new ({copied}) SELECT {copied} FROM nodes")
|
||||
await conn.exec_driver_sql("DROP TABLE nodes")
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes_new RENAME TO nodes")
|
||||
await conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)")
|
||||
except (OperationalError, IntegrityError) as exc:
|
||||
logger.warning("%s failed: %s", what, exc)
|
||||
finally:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _drop_legacy_node_columns() -> None:
|
||||
"""Remove the device columns from `nodes` (3.3.0) — SQLite table rebuild.
|
||||
|
||||
Runs only after the backfill has linked every device node to its inventory
|
||||
row. If any non-furniture node is still unlinked the drop is skipped and
|
||||
logged: the columns are the only remaining copy of that node's facts, and
|
||||
losing them is not recoverable. The skip then relaxes their NOT NULL instead,
|
||||
because the 3.3.0 model no longer writes them and the database must stay
|
||||
insertable while the backfill is retried on later boots.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||
present = {row[1] for row in info}
|
||||
if not (present & set(_LEGACY_NODE_COLUMNS)):
|
||||
return # Already migrated.
|
||||
|
||||
unlinked = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT COUNT(*) FROM nodes WHERE device_id IS NULL "
|
||||
"AND type NOT IN ('group', 'groupRect', 'text')"
|
||||
)
|
||||
).scalar()
|
||||
if unlinked:
|
||||
logger.warning(
|
||||
"Keeping the legacy node columns: %d node(s) have no inventory row. "
|
||||
"The backfill must link every device node before they can be dropped.",
|
||||
unlinked,
|
||||
)
|
||||
await _relax_legacy_node_columns(conn, list(info))
|
||||
return
|
||||
|
||||
logger.info("Migrating nodes: the device columns move to device_inventory")
|
||||
await _rebuild_nodes(
|
||||
conn,
|
||||
columns_sql=_NODE_COLUMNS_SQL,
|
||||
copied=_NODE_KEPT,
|
||||
what="nodes device-column drop",
|
||||
)
|
||||
|
||||
|
||||
async def _backfill_node_devices() -> None:
|
||||
"""Link existing canvas nodes to their Device Inventory rows (3.3.0).
|
||||
|
||||
Runs outside the DDL connection because the merge is Python-side, not SQL.
|
||||
Self-limiting: it only looks at nodes with no ``device_id``, so a second boot
|
||||
finds nothing to do. Never fatal — a failure here leaves the canvas working
|
||||
off its own columns, which still carry the data at this point.
|
||||
"""
|
||||
# Imported here: app.services imports app.db.models, which imports this module.
|
||||
from app.services.inventory_sync import backfill_node_devices
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
stats = await backfill_node_devices(session)
|
||||
if stats["linked"]:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Inventory backfill: linked %d node(s) — %d device(s) created, %d merged",
|
||||
stats["linked"], stats["created"], stats["merged"],
|
||||
)
|
||||
if stats.get("skipped"):
|
||||
logger.warning(
|
||||
"Inventory backfill: %d node(s) could not be linked; they keep their "
|
||||
"legacy columns and are retried on the next start.",
|
||||
stats["skipped"],
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Inventory backfill failed: %s", exc)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
+41
-19
@@ -28,44 +28,40 @@ class Design(Base):
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""How a device is drawn on one canvas.
|
||||
|
||||
A node owns presentation only — position, size, colours, icon, handles,
|
||||
nesting. What the device *is* (addresses, services, properties, notes,
|
||||
hardware, check method, live status) belongs to the `device_inventory` row
|
||||
named by `device_id`, so one device reads the same on every canvas. The API
|
||||
still reports both together: see `services/inventory_sync.hydrated_node`.
|
||||
"""
|
||||
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
ip: Mapped[str | None] = mapped_column(String)
|
||||
mac: Mapped[str | None] = mapped_column(String)
|
||||
os: Mapped[str | None] = mapped_column(String)
|
||||
status: Mapped[str] = mapped_column(String, default="unknown")
|
||||
check_method: Mapped[str | None] = mapped_column(String)
|
||||
check_target: Mapped[str | None] = mapped_column(String)
|
||||
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
# The Device Inventory row this node draws. NULL for canvas furniture
|
||||
# (group / groupRect / text), which represents nothing physical. Deleting a
|
||||
# node never deletes the device — the inventory outlives every canvas.
|
||||
device_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), index=True, nullable=True
|
||||
)
|
||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||
top_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||
left_handles: Mapped[int] = mapped_column(Integer, default=0)
|
||||
right_handles: Mapped[int] = mapped_column(Integer, default=0)
|
||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
||||
@@ -236,6 +232,32 @@ class InventoryDevice(Base):
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
# --- Curated device facts (3.3.0) -------------------------------------
|
||||
# The inventory row, not the canvas node, owns what the device *is*. A node
|
||||
# only says how it is drawn. `label`/`type` supersede friendly_name/
|
||||
# suggested_type for a device that reached a canvas; the older pair is kept
|
||||
# so discovery imports and the source filters keep working unchanged.
|
||||
label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
type: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
check_method: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
check_target: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# Live reachability from the status checker. NOT `status` — that column holds
|
||||
# the inventory lifecycle (pending/approved/hidden) and the two must not be
|
||||
# conflated.
|
||||
status_live: Mapped[str] = mapped_column(String, default="unknown")
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
# Transient (not persisted): populated per-request by the scan routes to report
|
||||
# how many canvases this device already appears on. Not a mapped column.
|
||||
canvas_count: int = 0
|
||||
|
||||
@@ -11,6 +11,17 @@ class NodeSave(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
# Round-tripped so a save keeps the node pointed at its inventory row. The
|
||||
# device fields below are still accepted at their canvas values and routed
|
||||
# to that row server-side.
|
||||
device_id: str | None = None
|
||||
# Which device facts this canvas edited since it loaded them, if the client
|
||||
# tracked that. The payload always carries a *full* copy of the device — it
|
||||
# was hydrated from the inventory row on load — so without this a save made
|
||||
# for nothing but a moved node would rewrite the row from a snapshot that may
|
||||
# be hours old. `None` (an older client, an import, the MCP server) keeps the
|
||||
# previous behaviour: every fact is a candidate write.
|
||||
changed_facts: list[str] | None = None
|
||||
hostname: str | None = None
|
||||
ip: str | None = None
|
||||
mac: str | None = None
|
||||
|
||||
@@ -85,6 +85,10 @@ class NodeUpdate(BaseModel):
|
||||
class NodeResponse(NodeBase):
|
||||
id: str
|
||||
design_id: str | None = None
|
||||
# The Device Inventory row this node draws; None for canvas furniture. The
|
||||
# device fields above are hydrated from that row on read, so the wire shape
|
||||
# is unchanged even though the row, not the node, owns them.
|
||||
device_id: str | None = None
|
||||
ieee_address: str | None = None
|
||||
last_seen: datetime | None = None
|
||||
last_scan: datetime | None = None
|
||||
|
||||
@@ -27,6 +27,26 @@ class InventoryDeviceResponse(BaseModel):
|
||||
# the node on approve; empty for scan/mesh sources that don't set them.
|
||||
properties: list[Any] = []
|
||||
discovered_at: datetime
|
||||
# Curated device facts (3.3.0). `label`/`type` supersede friendly_name/
|
||||
# suggested_type once the user has named the device; the older pair stays for
|
||||
# discovery imports and the source filters.
|
||||
label: str | None = None
|
||||
type: str | None = None
|
||||
notes: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
check_method: str | None = None
|
||||
check_target: str | None = None
|
||||
# Live reachability, distinct from `status` (the pending/approved/hidden
|
||||
# lifecycle).
|
||||
status_live: str = "unknown"
|
||||
last_seen: datetime | None = None
|
||||
last_scan: datetime | None = None
|
||||
response_time_ms: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
# Number of distinct canvases (designs) this device already appears on,
|
||||
# correlated by ip / ieee_address against existing nodes. Computed per-request.
|
||||
canvas_count: int = 0
|
||||
@@ -38,12 +58,22 @@ class InventoryDeviceResponse(BaseModel):
|
||||
node_last_modified: datetime | None = None
|
||||
node_last_seen: datetime | None = None
|
||||
|
||||
@field_validator("properties", "discovery_sources", mode="before")
|
||||
@field_validator("properties", "discovery_sources", "services", mode="before")
|
||||
@classmethod
|
||||
def _coerce_list(cls, v: Any) -> list[Any]:
|
||||
# Legacy rows (columns added by migration) have these = NULL.
|
||||
return v if isinstance(v, list) else []
|
||||
|
||||
@field_validator("status_live", mode="before")
|
||||
@classmethod
|
||||
def _coerce_status_live(cls, v: Any) -> str:
|
||||
return v if isinstance(v, str) and v else "unknown"
|
||||
|
||||
@field_validator("show_hardware", mode="before")
|
||||
@classmethod
|
||||
def _coerce_show_hardware(cls, v: Any) -> bool:
|
||||
return bool(v)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -73,6 +103,22 @@ class InventoryDeviceCreate(BaseModel):
|
||||
vendor: str | None = None
|
||||
properties: list[Any] = []
|
||||
discovery_source: str = "manual"
|
||||
# Curated fields, so a hand-made entry can carry everything the edit modal
|
||||
# shows rather than needing a create-then-PATCH round trip.
|
||||
os: str | None = None
|
||||
services: list[Any] = []
|
||||
friendly_name: str | None = None
|
||||
device_subtype: str | None = None
|
||||
label: str | None = None
|
||||
type: str | None = None
|
||||
notes: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
check_method: str | None = None
|
||||
check_target: str | None = None
|
||||
|
||||
@field_validator("discovery_source")
|
||||
@classmethod
|
||||
@@ -82,6 +128,39 @@ class InventoryDeviceCreate(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class InventoryDeviceUpdate(BaseModel):
|
||||
"""Partial edit of an inventory row — the write half of the detail modal.
|
||||
|
||||
Every field is optional and only what the client sends is applied
|
||||
(`exclude_unset`), so a caller editing one field never clears the rest.
|
||||
Lifecycle (`status`) and discovery bookkeeping (`discovery_source(s)`,
|
||||
`discovered_at`, `ieee_address`) are deliberately not editable here: those
|
||||
are owned by the approve/hide routes and the importers.
|
||||
"""
|
||||
|
||||
ip: str | None = None
|
||||
mac: str | None = None
|
||||
hostname: str | None = None
|
||||
os: str | None = None
|
||||
label: str | None = None
|
||||
type: str | None = None
|
||||
suggested_type: str | None = None
|
||||
friendly_name: str | None = None
|
||||
device_subtype: str | None = None
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
services: list[Any] | None = None
|
||||
properties: list[Any] | None = None
|
||||
notes: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool | None = None
|
||||
check_method: str | None = None
|
||||
check_target: str | None = None
|
||||
|
||||
|
||||
class ScanRunResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
|
||||
@@ -213,12 +213,16 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
|
||||
if mac_type == "iot":
|
||||
return "iot"
|
||||
|
||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
|
||||
priority = [
|
||||
"proxmox", "nas", "kvm", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch",
|
||||
]
|
||||
found: set[str] = set()
|
||||
for p in open_ports:
|
||||
port = p["port"]
|
||||
proto = p.get("protocol", "tcp")
|
||||
sig = match_port(port, proto)
|
||||
# Pass the HTTP-probe signals through: port-agnostic signatures (port: null,
|
||||
# e.g. PiKVM, Unraid) match on title/headers alone and are invisible otherwise.
|
||||
sig = match_service(port, proto, p.get("banner"), p.get("http_signals"))
|
||||
if sig and sig.get("suggested_node_type"):
|
||||
found.add(sig["suggested_node_type"])
|
||||
if port in _PORT_TYPE_HINTS:
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
"""Keep a canvas :class:`Node` and its Device Inventory row in step.
|
||||
|
||||
The inventory row owns what a device *is* — addresses, services, properties,
|
||||
notes, hardware, check method. A node owns only how that device is drawn on one
|
||||
canvas. This module holds the matching and merging rules shared by:
|
||||
|
||||
* the one-off backfill that links pre-3.3.0 nodes to inventory rows,
|
||||
* the approve / node-create paths, which link instead of copying,
|
||||
* the canvas save write-through, which pushes a node edit back to the row.
|
||||
|
||||
Nothing here commits — the caller owns the transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
from app.services.discovery_sources import add_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Canvas furniture: annotations, not hardware. These never get an inventory row.
|
||||
FURNITURE_TYPES = frozenset({"group", "groupRect", "text"})
|
||||
|
||||
# Source tag for a device that only ever existed as a canvas node — the backfill
|
||||
# mints its inventory row.
|
||||
CANVAS_SOURCE = "canvas"
|
||||
|
||||
# Scalar facts the inventory row owns. Order matters only for readability.
|
||||
DEVICE_SCALARS = (
|
||||
"hostname",
|
||||
"ip",
|
||||
"mac",
|
||||
"os",
|
||||
"notes",
|
||||
"cpu_count",
|
||||
"cpu_model",
|
||||
"ram_gb",
|
||||
"disk_gb",
|
||||
"check_method",
|
||||
"check_target",
|
||||
)
|
||||
|
||||
|
||||
def is_furniture(node_type: str | None) -> bool:
|
||||
return (node_type or "") in FURNITURE_TYPES
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split an ``ip`` field into individual addresses.
|
||||
|
||||
A node or device may carry several comma-separated addresses, so identity
|
||||
matching compares per token — same rule as ``node_dedupe._ip_tokens``.
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _same_ieee(left: str | None, right: str | None) -> bool:
|
||||
"""IEEE addresses compare case-insensitively — the same radio either way."""
|
||||
if _blank(left) or _blank(right):
|
||||
return False
|
||||
return str(left).lower() == str(right).lower()
|
||||
|
||||
|
||||
async def _ieee_owner(db: AsyncSession, ieee: str, *, other_than: str | None) -> InventoryDevice | None:
|
||||
"""The row already holding ``ieee``, if it is not ``other_than``.
|
||||
|
||||
``device_inventory.ieee_address`` is UNIQUE, so writing an address a second
|
||||
row owns raises. Callers ask first and leave the address where it is.
|
||||
"""
|
||||
stmt = select(InventoryDevice).where(func.lower(InventoryDevice.ieee_address) == ieee.lower())
|
||||
if other_than is not None:
|
||||
# `id != NULL` is NULL in SQL and would match nothing — only narrow when
|
||||
# there is a row to exclude.
|
||||
stmt = stmt.where(InventoryDevice.id != other_than)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
async def _drop_taken_ieee(
|
||||
db: AsyncSession, facts: Mapping[str, Any], device: InventoryDevice | None
|
||||
) -> Mapping[str, Any]:
|
||||
"""``facts`` without an ``ieee_address`` another inventory row already owns.
|
||||
|
||||
Returned unchanged in the common case. The column is UNIQUE, so writing a
|
||||
duplicate raises mid-merge — and during the backfill that would cost the node
|
||||
its link. The address belongs to whichever row holds it; identity for *this*
|
||||
device was established by ip or mac.
|
||||
"""
|
||||
ieee = facts.get("ieee_address")
|
||||
if _blank(ieee) or (device is not None and _same_ieee(device.ieee_address, ieee)):
|
||||
return facts
|
||||
owner = await _ieee_owner(db, str(ieee), other_than=device.id if device else None)
|
||||
if owner is None:
|
||||
return facts
|
||||
logger.warning(
|
||||
"IEEE %s is already held by device %s — leaving it off %s",
|
||||
ieee, owner.id, device.id if device else "the new row",
|
||||
)
|
||||
return {k: v for k, v in facts.items() if k != "ieee_address"}
|
||||
|
||||
|
||||
async def find_device_for(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
ieee: str | None,
|
||||
) -> InventoryDevice | None:
|
||||
"""Find the inventory row describing this host, or ``None``.
|
||||
|
||||
Precedence is ieee > ip > mac, matching ``find_duplicate_node`` and the
|
||||
bulk-approve skip order so a device is identified the same way everywhere.
|
||||
Hidden rows are eligible: a hidden device is still that device, and silently
|
||||
minting a second row for it would resurrect the duplicate the user hid.
|
||||
"""
|
||||
ip_toks = _ip_tokens(ip)
|
||||
conds = []
|
||||
if ieee:
|
||||
# Case-insensitive: `0x00124B00…` and `0x00124b00…` are the same radio,
|
||||
# and an exact comparison here would mint a second row for it — one the
|
||||
# UNIQUE index then refuses.
|
||||
conds.append(func.lower(InventoryDevice.ieee_address) == ieee.lower())
|
||||
for tok in ip_toks:
|
||||
# Narrow with a substring match, then confirm per token below — an exact
|
||||
# comparison misses rows holding several addresses.
|
||||
conds.append(InventoryDevice.ip.contains(tok))
|
||||
if mac:
|
||||
conds.append(InventoryDevice.mac == mac)
|
||||
if not conds:
|
||||
return None
|
||||
|
||||
candidates = (
|
||||
await db.execute(select(InventoryDevice).where(or_(*conds)).order_by(InventoryDevice.discovered_at))
|
||||
).scalars().all()
|
||||
|
||||
for device in candidates:
|
||||
if ieee and _same_ieee(device.ieee_address, ieee):
|
||||
return device
|
||||
for device in candidates:
|
||||
# "1.2.3.4" must not match "1.2.3.40" — confirm the token, don't trust
|
||||
# the SQL `contains`.
|
||||
if ip_toks and set(_ip_tokens(device.ip)) & set(ip_toks):
|
||||
return device
|
||||
for device in candidates:
|
||||
if mac and device.mac == mac:
|
||||
return device
|
||||
return None
|
||||
|
||||
|
||||
def merge_properties(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
|
||||
"""Union two property lists on ``key`` (case-insensitive); incoming wins.
|
||||
|
||||
Order-stable: existing keys keep their position, new ones are appended, so a
|
||||
user's arrangement survives a merge.
|
||||
"""
|
||||
out: list[Any] = [dict(p) if isinstance(p, dict) else p for p in (base or [])]
|
||||
index: dict[str, int] = {}
|
||||
for i, prop in enumerate(out):
|
||||
if isinstance(prop, dict) and prop.get("key") is not None:
|
||||
index[str(prop["key"]).lower()] = i
|
||||
|
||||
for prop in incoming or []:
|
||||
if not isinstance(prop, dict) or prop.get("key") is None:
|
||||
if prop not in out:
|
||||
out.append(prop)
|
||||
continue
|
||||
key = str(prop["key"]).lower()
|
||||
pos = index.get(key)
|
||||
if pos is None:
|
||||
out.append(dict(prop))
|
||||
index[key] = len(out) - 1
|
||||
continue
|
||||
current = out[pos]
|
||||
if not isinstance(current, dict):
|
||||
out[pos] = dict(prop)
|
||||
continue
|
||||
merged = {**current, **{k: v for k, v in prop.items() if not _blank(v)}}
|
||||
# Keys match case-insensitively but the display spelling is the user's —
|
||||
# "rack" arriving must not rewrite their "Rack".
|
||||
merged["key"] = current.get("key", prop["key"])
|
||||
# `visible` is a real False, not an empty value — carry it explicitly.
|
||||
if "visible" in prop:
|
||||
merged["visible"] = prop["visible"]
|
||||
out[pos] = merged
|
||||
return out
|
||||
|
||||
|
||||
def _service_key(svc: Any) -> Any:
|
||||
if not isinstance(svc, dict):
|
||||
return repr(svc)
|
||||
return (svc.get("port"), svc.get("protocol"), (svc.get("service_name") or "").lower())
|
||||
|
||||
|
||||
def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
|
||||
"""Union two service lists on (port, protocol, name); incoming wins."""
|
||||
out: list[Any] = [dict(s) if isinstance(s, dict) else s for s in (base or [])]
|
||||
index = {_service_key(s): i for i, s in enumerate(out)}
|
||||
for svc in incoming or []:
|
||||
key = _service_key(svc)
|
||||
pos = index.get(key)
|
||||
if pos is None:
|
||||
out.append(dict(svc) if isinstance(svc, dict) else svc)
|
||||
index[key] = len(out) - 1
|
||||
elif isinstance(svc, dict) and isinstance(out[pos], dict):
|
||||
out[pos] = {**out[pos], **svc}
|
||||
else:
|
||||
out[pos] = svc
|
||||
return out
|
||||
|
||||
|
||||
# Observations rather than edits: the checker and the scanner write these, so a
|
||||
# client never lists them as changed and they survive a `changed_fields` filter.
|
||||
_LIVE_FACT_FIELDS = frozenset({"status", "last_seen", "last_scan", "response_time_ms"})
|
||||
|
||||
|
||||
def changed_facts(device: InventoryDevice, facts: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The subset of ``facts`` that actually differs from the row.
|
||||
|
||||
A canvas save sends a *full* copy of the device — the facts were hydrated
|
||||
into the node when the canvas loaded — so a save triggered by nothing but a
|
||||
node being dragged would otherwise rewrite the row from a snapshot that may
|
||||
be minutes or hours old, silently reverting an edit made meanwhile in the
|
||||
inventory modal, on another canvas, or by the scanner. Narrowing to what the
|
||||
sender changed turns the write-through from "push my whole snapshot" into
|
||||
"push my edit", so two writers only collide on the same field.
|
||||
|
||||
The comparison mirrors :func:`merge_facts_into_device`: a blank incoming
|
||||
value is not a change (it never clears an established one), and a list
|
||||
counts as changed only when it would actually be replaced by a different
|
||||
one. Ambiguity is resolved toward reporting a change — a false positive is
|
||||
the old behaviour for that field, a false negative would drop a real edit.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
for field in (*DEVICE_SCALARS, "label", "type"):
|
||||
incoming = facts.get(field)
|
||||
if _blank(incoming) or incoming == getattr(device, field, None):
|
||||
continue
|
||||
out[field] = incoming
|
||||
|
||||
if not _blank(facts.get("ieee_address")) and _blank(device.ieee_address):
|
||||
out["ieee_address"] = facts["ieee_address"]
|
||||
if facts.get("show_hardware") and not device.show_hardware:
|
||||
out["show_hardware"] = facts["show_hardware"]
|
||||
|
||||
if "properties" in facts and list(facts["properties"] or []) != list(device.properties or []):
|
||||
out["properties"] = facts["properties"]
|
||||
if "services" in facts and list(facts["services"] or []) != list(device.services or []):
|
||||
out["services"] = facts["services"]
|
||||
|
||||
# Live observations, not edits: carried through only where the merge would
|
||||
# have used them — filling a row that has never been checked.
|
||||
if facts.get("status") and device.status_live in (None, "", "unknown"):
|
||||
out["status"] = facts["status"]
|
||||
for field in ("last_seen", "last_scan", "response_time_ms"):
|
||||
if facts.get(field) is not None:
|
||||
out[field] = facts[field]
|
||||
return out
|
||||
|
||||
|
||||
def merge_facts_into_device(
|
||||
device: InventoryDevice,
|
||||
facts: Mapping[str, Any],
|
||||
*,
|
||||
overwrite_scalars: bool,
|
||||
replace_lists: bool,
|
||||
) -> None:
|
||||
"""Fold one view of a device into its inventory row, in place.
|
||||
|
||||
``facts`` is a plain mapping — what a canvas save sent, or what a legacy
|
||||
node's columns held — so this rule lives in one place regardless of where
|
||||
the view came from.
|
||||
|
||||
Two independent knobs, because the callers need three combinations:
|
||||
|
||||
* ``overwrite_scalars`` — a non-blank incoming value replaces the row's.
|
||||
True for the backfill (nodes are visited oldest-edit-first, so the most
|
||||
recently edited canvas is the last writer and wins) and for a user's save.
|
||||
False on approve, where the row was just discovered and the node is only a
|
||||
placement. A blank *never* clears an established value in either mode.
|
||||
* ``replace_lists`` — properties/services are taken wholesale rather than
|
||||
unioned. True only for a user's save: otherwise a property they deleted
|
||||
would come straight back on the next one. The backfill unions, so nothing
|
||||
any canvas recorded is lost. It applies list by list: a list absent from
|
||||
``facts`` is left alone even in replace mode, so a partial update never
|
||||
clears the one it did not send.
|
||||
"""
|
||||
for field in (*DEVICE_SCALARS, "label", "type"):
|
||||
incoming = facts.get(field)
|
||||
if _blank(incoming):
|
||||
continue
|
||||
if overwrite_scalars or _blank(getattr(device, field, None)):
|
||||
setattr(device, field, incoming)
|
||||
|
||||
if not _blank(facts.get("ieee_address")) and _blank(device.ieee_address):
|
||||
# Identity, never overwritten — two IEEEs mean two devices.
|
||||
device.ieee_address = facts["ieee_address"]
|
||||
if facts.get("show_hardware") and not device.show_hardware:
|
||||
device.show_hardware = True
|
||||
|
||||
# Per list, and only for one the caller actually sent: a partial update
|
||||
# carrying properties alone must leave services as they were, not blank them.
|
||||
if replace_lists and "properties" in facts:
|
||||
device.properties = list(facts["properties"] or [])
|
||||
else:
|
||||
device.properties = merge_properties(device.properties, facts.get("properties"))
|
||||
if replace_lists and "services" in facts:
|
||||
device.services = list(facts["services"] or [])
|
||||
else:
|
||||
device.services = merge_services(device.services, facts.get("services"))
|
||||
|
||||
# Live status: keep the freshest observation rather than the last writer.
|
||||
last_seen, status, last_scan = facts.get("last_seen"), facts.get("status"), facts.get("last_scan")
|
||||
if last_seen and (device.last_seen is None or last_seen > device.last_seen):
|
||||
device.last_seen = last_seen
|
||||
device.status_live = status or device.status_live
|
||||
device.response_time_ms = facts.get("response_time_ms")
|
||||
elif device.status_live in (None, "", "unknown") and status:
|
||||
device.status_live = status
|
||||
if last_scan and (device.last_scan is None or last_scan > device.last_scan):
|
||||
device.last_scan = last_scan
|
||||
|
||||
|
||||
def device_from_facts(facts: Mapping[str, Any]) -> InventoryDevice:
|
||||
"""Mint the inventory row for a device that has none.
|
||||
|
||||
Tagged ``canvas`` so the inventory filters can tell hand-drawn gear apart
|
||||
from anything a scan or import found.
|
||||
"""
|
||||
return InventoryDevice(
|
||||
label=facts.get("label"),
|
||||
type=facts.get("type"),
|
||||
hostname=facts.get("hostname"),
|
||||
ip=facts.get("ip"),
|
||||
mac=facts.get("mac"),
|
||||
os=facts.get("os"),
|
||||
ieee_address=facts.get("ieee_address"),
|
||||
services=list(facts.get("services") or []),
|
||||
properties=list(facts.get("properties") or []),
|
||||
notes=facts.get("notes"),
|
||||
cpu_count=facts.get("cpu_count"),
|
||||
cpu_model=facts.get("cpu_model"),
|
||||
ram_gb=facts.get("ram_gb"),
|
||||
disk_gb=facts.get("disk_gb"),
|
||||
show_hardware=bool(facts.get("show_hardware")),
|
||||
check_method=facts.get("check_method"),
|
||||
check_target=facts.get("check_target"),
|
||||
suggested_type=facts.get("type"),
|
||||
friendly_name=facts.get("label"),
|
||||
# On a canvas already, so it is past the pending queue.
|
||||
status="approved",
|
||||
status_live=facts.get("status") or "unknown",
|
||||
last_seen=facts.get("last_seen"),
|
||||
last_scan=facts.get("last_scan"),
|
||||
response_time_ms=facts.get("response_time_ms"),
|
||||
discovery_source=CANVAS_SOURCE,
|
||||
discovery_sources=[CANVAS_SOURCE],
|
||||
)
|
||||
|
||||
|
||||
async def link_facts(
|
||||
db: AsyncSession,
|
||||
node: Node,
|
||||
facts: Mapping[str, Any],
|
||||
*,
|
||||
overwrite_scalars: bool = False,
|
||||
replace_lists: bool = False,
|
||||
only_changed: bool = False,
|
||||
changed_fields: list[str] | None = None,
|
||||
) -> InventoryDevice | None:
|
||||
"""Point one node at its inventory row, creating or merging as needed.
|
||||
|
||||
``facts`` is this node's view of the device — the fields a canvas save sent,
|
||||
or a legacy node's columns during the backfill. Returns the row, or ``None``
|
||||
for canvas furniture. Flushes so a freshly minted row has an id to link to,
|
||||
but does not commit.
|
||||
|
||||
Two narrowings turn a save from "push my whole snapshot" into "push my edit",
|
||||
and they compose. Identity matching always uses the full ``facts`` — the row
|
||||
has to be found before it can be narrowed against.
|
||||
|
||||
* ``changed_fields`` — what the sender says it edited since it loaded the
|
||||
device. Authoritative: a fact absent from the list is not written even when
|
||||
it differs, because the difference means the *row* moved on, not the sender.
|
||||
* ``only_changed`` — drop facts already equal to the row (see
|
||||
:func:`changed_facts`), so a no-op save writes nothing at all.
|
||||
"""
|
||||
if is_furniture(node.type):
|
||||
node.device_id = None
|
||||
return None
|
||||
|
||||
device = None
|
||||
if node.device_id:
|
||||
device = await db.get(InventoryDevice, node.device_id)
|
||||
if device is None:
|
||||
device = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
|
||||
# The IEEE is UNIQUE across the inventory. Where another row already owns the
|
||||
# one these facts carry, drop it rather than write a duplicate: identity has
|
||||
# been resolved to *this* device by ip or mac, and the address stays with the
|
||||
# row holding it.
|
||||
facts = await _drop_taken_ieee(db, facts, device)
|
||||
|
||||
if device is None:
|
||||
device = device_from_facts(facts)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
else:
|
||||
merged = dict(facts)
|
||||
if changed_fields is not None:
|
||||
keep = set(changed_fields) | _LIVE_FACT_FIELDS
|
||||
merged = {k: v for k, v in merged.items() if k in keep}
|
||||
if only_changed:
|
||||
merged = changed_facts(device, merged)
|
||||
merge_facts_into_device(
|
||||
device,
|
||||
merged,
|
||||
overwrite_scalars=overwrite_scalars,
|
||||
replace_lists=replace_lists,
|
||||
)
|
||||
device.discovery_sources = add_source(device.discovery_sources, CANVAS_SOURCE)
|
||||
|
||||
node.device_id = device.id
|
||||
return device
|
||||
|
||||
|
||||
def node_columns(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The subset of a node payload that is still a `nodes` column.
|
||||
|
||||
The wire shape carries the device facts flat on the node; they belong to the
|
||||
inventory row now, so they are dropped here and applied through
|
||||
:func:`link_facts` instead.
|
||||
"""
|
||||
allowed = {c.name for c in Node.__table__.columns}
|
||||
return {k: v for k, v in payload.items() if k in allowed}
|
||||
|
||||
|
||||
# Fields that are both a node column and a device fact: the node keeps a copy so
|
||||
# a half-migrated database still renders, but the row is the truth.
|
||||
_SHARED_FIELDS = ("label", "type")
|
||||
|
||||
# Everything the inventory row owns, as it appears in a node payload.
|
||||
DEVICE_FACT_FIELDS = (
|
||||
*DEVICE_SCALARS, "services", "properties", "show_hardware", "status", *_SHARED_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
def facts_from_update(sent: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The device facts inside a partial node update — only what was sent.
|
||||
|
||||
An omitted field must not clear the row, so unsent keys are simply absent.
|
||||
"""
|
||||
return {k: v for k, v in sent.items() if k in DEVICE_FACT_FIELDS}
|
||||
|
||||
|
||||
def facts_from_payload(payload: Mapping[str, Any], *, label: str, node_type: str) -> dict[str, Any]:
|
||||
"""The device facts inside a node save/create payload.
|
||||
|
||||
The wire shape still carries them flat on the node, so this is where they
|
||||
are separated from the presentation fields the node itself keeps.
|
||||
"""
|
||||
facts: dict[str, Any] = {
|
||||
field: payload.get(field)
|
||||
for field in (*DEVICE_SCALARS, "services", "properties", "show_hardware", "status")
|
||||
}
|
||||
facts["label"] = label
|
||||
facts["type"] = node_type
|
||||
return facts
|
||||
|
||||
|
||||
async def load_devices_for(db: AsyncSession, nodes: list[Node]) -> dict[str, InventoryDevice]:
|
||||
"""Fetch the inventory rows a batch of nodes points at, keyed by device id."""
|
||||
ids = {n.device_id for n in nodes if n.device_id}
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(ids)))
|
||||
).scalars().all()
|
||||
return {d.id: d for d in rows}
|
||||
|
||||
|
||||
def hydrated_node(node: Node, device: InventoryDevice | None) -> dict[str, Any]:
|
||||
"""A node as the API reports it: presentation from the node, facts from the row.
|
||||
|
||||
The device fields stay on the wire exactly where they have always been, so
|
||||
every reader — the canvas, the live view, the MCP server — is unaffected by
|
||||
the split. Canvas furniture has no row and simply reports the defaults.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
c.name: getattr(node, c.name) for c in node.__table__.columns
|
||||
}
|
||||
if device is None:
|
||||
return payload
|
||||
|
||||
for field in DEVICE_SCALARS:
|
||||
payload[field] = getattr(device, field, None)
|
||||
payload["label"] = device.label or node.label
|
||||
payload["type"] = device.type or node.type
|
||||
payload["services"] = device.services or []
|
||||
payload["properties"] = device.properties or []
|
||||
payload["show_hardware"] = bool(device.show_hardware)
|
||||
payload["ieee_address"] = device.ieee_address
|
||||
payload["status"] = device.status_live or "unknown"
|
||||
payload["last_seen"] = device.last_seen
|
||||
payload["last_scan"] = device.last_scan
|
||||
payload["response_time_ms"] = device.response_time_ms
|
||||
return payload
|
||||
|
||||
|
||||
# The device columns `nodes` carried before 3.3.0. They are read once, by the
|
||||
# backfill, and then dropped — so they are named here as raw SQL rather than as
|
||||
# model attributes that no longer exist.
|
||||
_LEGACY_NODE_COLUMNS = (
|
||||
"hostname", "ip", "mac", "os", "status", "check_method", "check_target",
|
||||
"services", "notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb",
|
||||
"show_hardware", "properties", "ieee_address", "last_seen", "last_scan",
|
||||
"response_time_ms",
|
||||
)
|
||||
|
||||
|
||||
async def _legacy_columns_present(db: AsyncSession) -> list[str]:
|
||||
"""Which pre-3.3.0 device columns still exist on `nodes`."""
|
||||
rows = (await db.execute(text("PRAGMA table_info(nodes)"))).all()
|
||||
present = {r[1] for r in rows}
|
||||
return [c for c in _LEGACY_NODE_COLUMNS if c in present]
|
||||
|
||||
|
||||
def _decode_json(value: Any) -> Any:
|
||||
"""Raw SQL hands back JSON columns as text; the ORM would have decoded them."""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return value
|
||||
|
||||
|
||||
async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
"""Link every pre-3.3.0 canvas node to a Device Inventory row.
|
||||
|
||||
Non-destructive: it writes ``nodes.device_id`` and fills the inventory row,
|
||||
and never deletes a node or a row. Two nodes on two canvases describing the
|
||||
same host converge on one row — that convergence is the point.
|
||||
|
||||
Nodes are processed oldest-edit-first so that, where two canvases disagree
|
||||
on a scalar, the most recently edited node is the last writer and wins;
|
||||
properties and services are unioned, so nothing any canvas recorded is lost.
|
||||
|
||||
Reads the legacy columns with raw SQL because the model no longer declares
|
||||
them — this runs on the boot that drops them, once. Returns counts for the
|
||||
boot log, ``skipped`` among them: a node whose merge violates a constraint is
|
||||
left unlinked instead of aborting the run. Does not commit.
|
||||
"""
|
||||
legacy = await _legacy_columns_present(db)
|
||||
if not legacy:
|
||||
# Already migrated: the columns are gone, so nothing can be left to read.
|
||||
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
placeholders = ", ".join(legacy)
|
||||
furniture = ", ".join(f"'{t}'" for t in sorted(FURNITURE_TYPES))
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f"SELECT id, label, type, design_id, {placeholders} FROM nodes "
|
||||
f"WHERE device_id IS NULL AND type NOT IN ({furniture}) "
|
||||
"ORDER BY updated_at, created_at, id"
|
||||
)
|
||||
)
|
||||
).mappings().all()
|
||||
if not rows:
|
||||
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
linked = created = merged = skipped = 0
|
||||
for row in rows:
|
||||
facts: dict[str, Any] = {c: row[c] for c in legacy}
|
||||
facts["services"] = _decode_json(facts.get("services")) or []
|
||||
facts["properties"] = _decode_json(facts.get("properties")) or []
|
||||
facts["label"] = row["label"]
|
||||
facts["type"] = row["type"]
|
||||
|
||||
# One savepoint per node: a row the merge cannot write — a duplicate
|
||||
# IEEE, a constraint no longer met — is dropped on its own rather than
|
||||
# taking the whole backfill with it. Every node it does not link keeps
|
||||
# its legacy columns, so nothing is lost and the next boot retries.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
device = await link_facts(db, node, facts, overwrite_scalars=True)
|
||||
if device is None:
|
||||
continue
|
||||
await db.flush()
|
||||
except (IntegrityError, OperationalError) as exc:
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"Inventory backfill: node %s (%s) skipped — %s", row["id"], row["label"], exc
|
||||
)
|
||||
continue
|
||||
|
||||
linked += 1
|
||||
if existing is None:
|
||||
created += 1
|
||||
logger.info(
|
||||
"Inventory backfill: node %s (%s) created device %s", node.id, row["label"], device.id
|
||||
)
|
||||
else:
|
||||
merged += 1
|
||||
logger.info(
|
||||
"Inventory backfill: node %s (%s, design %s) merged into device %s",
|
||||
node.id, row["label"], row["design_id"], device.id,
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
return {"linked": linked, "created": created, "merged": merged, "skipped": skipped}
|
||||
@@ -1,43 +1,29 @@
|
||||
"""Collapse *true* duplicate canvas nodes: same ``ieee_address`` **and** same
|
||||
``design_id`` (i.e. the same device placed twice on the *same* canvas).
|
||||
"""Duplicate canvas nodes: the same device drawn twice on the *same* canvas.
|
||||
|
||||
The same device legitimately appears on multiple canvases — placing a device on
|
||||
another design creates a second :class:`Node` for that IEEE by design (see the
|
||||
per-design guard in ``bulk_approve_devices``). Those cross-design rows are NOT
|
||||
duplicates and must be preserved. Only two rows sharing both the IEEE *and* the
|
||||
design are corrupt, and only those are collapsed here.
|
||||
A device legitimately appears on several canvases — one :class:`Node` per
|
||||
design, all pointing at one ``device_inventory`` row. Two nodes drawing that row
|
||||
on the *same* design are the corrupt case, and the only one collapsed here.
|
||||
|
||||
This module provides an idempotent, **loss-free** repair: per ``(ieee,
|
||||
design_id)`` group with >1 node it keeps the oldest as canonical, merges the
|
||||
extras' data into it (properties, missing scalar fields, services), re-points
|
||||
every edge and ``parent_id`` reference onto the canonical node, then deletes the
|
||||
extras. Edges are de-duplicated and self-loops dropped after re-pointing so no
|
||||
dangling or redundant links remain.
|
||||
Identity is the device link, not the addresses: a node names the row it draws,
|
||||
so "same device" is an id comparison rather than a guess across ieee/ip/mac.
|
||||
The repair is idempotent — the oldest node stays, edges and ``parent_id``
|
||||
references are re-pointed onto it, then the extras go. No device data is lost in
|
||||
the process because none of it lives on the node any more.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Edge, Node
|
||||
from app.services.zigbee_service import merge_zigbee_properties
|
||||
from app.services.inventory_sync import find_device_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split a Node ``ip`` field into individual, whitespace-trimmed addresses.
|
||||
|
||||
The canvas stores several addresses in one comma-separated string once a
|
||||
user adds e.g. an IPv6 address, so identity matching must compare per token.
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
async def find_duplicate_node(
|
||||
db: AsyncSession,
|
||||
design_id: str | None,
|
||||
@@ -45,153 +31,84 @@ async def find_duplicate_node(
|
||||
mac: str | None,
|
||||
ieee: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return conflict details if an equivalent node (same ieee, ip OR mac)
|
||||
already sits on ``design_id``, else ``None``.
|
||||
"""Return conflict details if this device is already drawn on ``design_id``.
|
||||
|
||||
Scoped to a single design on purpose: the same device may legitimately
|
||||
appear on several canvases (one :class:`Node` per design). Only a second
|
||||
node for the same ieee/ip/mac on the *same* design is a duplicate — which
|
||||
the create/approve endpoints turn into a 409 so the UI can offer "go to
|
||||
existing" vs "add duplicate anyway", uniformly for IEEE (Zigbee/Z-Wave) and
|
||||
plain IP/ARP hosts.
|
||||
Identity is resolved once, by the inventory row the addresses point at
|
||||
(``find_device_for``: ieee > ip > mac), and a duplicate is simply a second
|
||||
node on the same design drawing that row. Scoped to one design on purpose:
|
||||
the same device may legitimately appear on several canvases.
|
||||
|
||||
(:func:`dedupe_nodes_by_ieee` still repairs *pre-existing* same-canvas IEEE
|
||||
duplicates; this guard prevents new ones unless the user forces them.)
|
||||
The create/approve endpoints turn this into a 409 so the UI can offer "go to
|
||||
existing" vs "add duplicate anyway".
|
||||
"""
|
||||
ip_toks = _ip_tokens(ip)
|
||||
conds = []
|
||||
if ieee:
|
||||
conds.append(Node.ieee_address == ieee)
|
||||
# A node's ip may hold several comma-separated addresses (e.g. an IPv6 added
|
||||
# before the IPv4), so narrow with a substring match then confirm per-token
|
||||
# in Python — exact ``Node.ip == ip`` would miss those rows (issue #258).
|
||||
for tok in ip_toks:
|
||||
conds.append(Node.ip.contains(tok))
|
||||
if mac:
|
||||
conds.append(Node.mac == mac)
|
||||
if not conds:
|
||||
device = await find_device_for(db, ip=ip, mac=mac, ieee=ieee)
|
||||
if device is None:
|
||||
return None
|
||||
candidates = (
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Node).where(Node.design_id == design_id, or_(*conds))
|
||||
select(Node)
|
||||
.where(Node.design_id == design_id, Node.device_id == device.id)
|
||||
.order_by(Node.created_at, Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# Confirm a real match (the ip ``contains`` above can false-positive, e.g.
|
||||
# "1.2.3.4" inside "1.2.3.40"), preferring ieee > ip > mac.
|
||||
def _matches(node: Node) -> tuple[str, str | None] | None:
|
||||
if ieee and node.ieee_address == ieee:
|
||||
return "ieee", node.ieee_address
|
||||
node_toks = set(_ip_tokens(node.ip))
|
||||
hit = next((t for t in ip_toks if t in node_toks), None)
|
||||
if hit is not None:
|
||||
return "ip", hit
|
||||
if mac and node.mac == mac:
|
||||
return "mac", mac
|
||||
).scalars().first()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
existing = None
|
||||
matched: tuple[str, str | None] | None = None
|
||||
for node in candidates:
|
||||
m = _matches(node)
|
||||
if m is not None:
|
||||
existing, matched = node, m
|
||||
break
|
||||
if existing is None or matched is None:
|
||||
return None
|
||||
match, value = matched
|
||||
# Report which address identified the device, as the UI prints it, in the
|
||||
# same precedence find_device_for used: ieee > ip > mac.
|
||||
match: str
|
||||
value: str | None
|
||||
device_ips = {t.strip() for t in (device.ip or "").split(",") if t.strip()}
|
||||
shared_ip = next((t.strip() for t in (ip or "").split(",") if t.strip() in device_ips), None)
|
||||
if ieee and device.ieee_address == ieee:
|
||||
match, value = "ieee", ieee
|
||||
elif shared_ip:
|
||||
match, value = "ip", shared_ip
|
||||
elif mac and device.mac == mac:
|
||||
match, value = "mac", mac
|
||||
else:
|
||||
match, value = "ip", ip
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_node_id": existing.id,
|
||||
"existing_label": existing.label,
|
||||
"existing_label": device.label or existing.label,
|
||||
"match": match,
|
||||
"value": value,
|
||||
}
|
||||
|
||||
# Scalar Node fields worth carrying over from a duplicate when the canonical
|
||||
# node has no value. Positions, design, parent and identity fields are left on
|
||||
# the canonical node untouched (that's the row the user actually placed).
|
||||
_FILLABLE_FIELDS = (
|
||||
"hostname",
|
||||
"ip",
|
||||
"mac",
|
||||
"os",
|
||||
"check_method",
|
||||
"check_target",
|
||||
"notes",
|
||||
"cpu_count",
|
||||
"cpu_model",
|
||||
"ram_gb",
|
||||
"disk_gb",
|
||||
"custom_icon",
|
||||
"last_seen",
|
||||
"last_scan",
|
||||
)
|
||||
|
||||
async def dedupe_nodes_by_device(db: AsyncSession) -> int:
|
||||
"""Merge duplicate nodes drawing the same device on the same canvas.
|
||||
|
||||
def _merge_services(
|
||||
a: list[Any] | None, b: list[Any] | None
|
||||
) -> list[Any]:
|
||||
"""Union two service lists, de-duplicated, order-stable."""
|
||||
out: list[Any] = list(a or [])
|
||||
seen = {repr(s) for s in out}
|
||||
for s in b or []:
|
||||
if repr(s) not in seen:
|
||||
out.append(s)
|
||||
seen.add(repr(s))
|
||||
return out
|
||||
|
||||
|
||||
def _merge_into_canonical(canonical: Node, dup: Node) -> None:
|
||||
"""Fold ``dup``'s data into ``canonical`` in place (no field lost)."""
|
||||
canonical.properties = merge_zigbee_properties(
|
||||
canonical.properties, dup.properties or []
|
||||
)
|
||||
canonical.services = _merge_services(canonical.services, dup.services)
|
||||
for field in _FILLABLE_FIELDS:
|
||||
if getattr(canonical, field, None) in (None, "") and getattr(dup, field, None) not in (None, ""):
|
||||
setattr(canonical, field, getattr(dup, field))
|
||||
# Prefer a human label over a bare IEEE/hex fallback.
|
||||
if (not canonical.label or canonical.label == canonical.ieee_address) and dup.label:
|
||||
canonical.label = dup.label
|
||||
|
||||
|
||||
async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
||||
"""Merge duplicate nodes sharing an ``ieee_address`` AND ``design_id``.
|
||||
|
||||
Returns the number of nodes removed. Idempotent: a no-op when every
|
||||
``(ieee, design)`` pair maps to at most one node. Nodes with the same IEEE
|
||||
on *different* designs are left untouched (valid cross-canvas placement).
|
||||
Returns the number of nodes removed. Idempotent. Nodes drawing one device on
|
||||
*different* designs are left alone — that is valid cross-canvas placement.
|
||||
The device facts live on the inventory row, so nothing has to be merged out
|
||||
of the extras: only edges and parent links are re-pointed before they go.
|
||||
Does not commit — the caller owns the transaction.
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Node)
|
||||
.where(Node.ieee_address.is_not(None))
|
||||
.order_by(Node.ieee_address, Node.created_at, Node.id)
|
||||
.where(Node.device_id.is_not(None))
|
||||
.order_by(Node.device_id, Node.created_at, Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
groups: dict[tuple[str, str | None], list[Node]] = {}
|
||||
for node in rows:
|
||||
groups.setdefault((node.ieee_address, node.design_id), []).append(node) # type: ignore[arg-type]
|
||||
groups.setdefault((node.device_id, node.design_id), []).append(node) # type: ignore[arg-type]
|
||||
|
||||
removed = 0
|
||||
for (ieee, _design), nodes in groups.items():
|
||||
for (device_id, _design), nodes in groups.items():
|
||||
if len(nodes) < 2:
|
||||
continue
|
||||
canonical, *dups = nodes # oldest first (ordered above)
|
||||
dup_ids = {d.id for d in dups}
|
||||
|
||||
for dup in dups:
|
||||
_merge_into_canonical(canonical, dup)
|
||||
|
||||
# Re-point edges + parents, then drop self-loops / duplicates.
|
||||
edges = (
|
||||
await db.execute(
|
||||
select(Edge).where(
|
||||
Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids)
|
||||
)
|
||||
select(Edge).where(Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
for edge in edges:
|
||||
@@ -200,19 +117,15 @@ async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
||||
if edge.target in dup_ids:
|
||||
edge.target = canonical.id
|
||||
|
||||
# Re-point children whose parent was a duplicate.
|
||||
children = (
|
||||
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
|
||||
).scalars().all()
|
||||
for child in children:
|
||||
child.parent_id = canonical.id
|
||||
|
||||
# Collapse self-loops and now-redundant parallel edges.
|
||||
all_edges = (
|
||||
await db.execute(
|
||||
select(Edge).where(
|
||||
(Edge.source == canonical.id) | (Edge.target == canonical.id)
|
||||
)
|
||||
select(Edge).where((Edge.source == canonical.id) | (Edge.target == canonical.id))
|
||||
)
|
||||
).scalars().all()
|
||||
seen_pairs: set[tuple[str, str, str]] = set()
|
||||
@@ -232,8 +145,8 @@ async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
||||
removed += 1
|
||||
|
||||
logger.info(
|
||||
"Deduped IEEE %s: merged %d duplicate node(s) into %s",
|
||||
ieee, len(dups), canonical.id,
|
||||
"Deduped device %s: merged %d duplicate node(s) into %s",
|
||||
device_id, len(dups), canonical.id,
|
||||
)
|
||||
|
||||
if removed:
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
from app.db.models import InventoryDevice, ScanRun
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||
from app.services.http_probe import probe_open_ports
|
||||
@@ -599,6 +599,10 @@ async def run_scan(
|
||||
# Merged row carries both sources (e.g. ["proxmox", "arp"]).
|
||||
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
|
||||
# status preserved — an approved device stays approved.
|
||||
# Stamp last_scan on the row so every canvas drawing the device
|
||||
# shows when the scanner last observed it. The row is the one
|
||||
# place that fact belongs; a node only draws it.
|
||||
keep.last_scan = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
@@ -610,25 +614,10 @@ async def run_scan(
|
||||
status="pending",
|
||||
discovery_source=discovery_source,
|
||||
discovery_sources=[discovery_source],
|
||||
last_scan=datetime.now(timezone.utc),
|
||||
))
|
||||
devices_found += 1
|
||||
|
||||
# Stamp last_scan on any canvas node that matches this device by IP
|
||||
# (or MAC, when known) so the inventory shows when the scanner last
|
||||
# observed it. Match both the normalized and raw MAC so a legacy
|
||||
# canvas node whose mac predates normalization still matches. Across
|
||||
# designs.
|
||||
node_match = [Node.ip == ip]
|
||||
for m in {norm_mac, host.get("mac")}:
|
||||
if m:
|
||||
node_match.append(Node.mac == m)
|
||||
matching_nodes = (await db.execute(
|
||||
select(Node).where(or_(*node_match))
|
||||
)).scalars().all()
|
||||
scanned_at = datetime.now(timezone.utc)
|
||||
for node in matching_nodes:
|
||||
node.last_scan = scanned_at
|
||||
|
||||
await db.commit()
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
|
||||
@@ -138,6 +138,41 @@ def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||
return f"[{host}]" if _is_ipv6(host) else host
|
||||
|
||||
|
||||
def _parse_override(raw: str) -> tuple[str, str | None, int | None]:
|
||||
"""Split a service `host` override into (hostname, scheme, port).
|
||||
|
||||
Mirrors the frontend `parseHostParts`: a node can serve several domains, so
|
||||
a service may carry its own host, optionally with a scheme and a port
|
||||
(`blog.example.com`, `blog.example.com:8443`, `https://blog.example.com`).
|
||||
"""
|
||||
# Like a node ip, an override may list several hosts — the first one wins,
|
||||
# same as the frontend's `splitFirstHost`.
|
||||
rest = raw.split(",")[0].strip()
|
||||
scheme: str | None = None
|
||||
for prefix in ("https://", "http://"):
|
||||
if rest.lower().startswith(prefix):
|
||||
scheme = prefix[:-3]
|
||||
rest = rest[len(prefix):]
|
||||
break
|
||||
rest = rest.split("/", 1)[0]
|
||||
|
||||
if rest.startswith("["):
|
||||
closing = rest.find("]")
|
||||
if closing == -1:
|
||||
return rest, scheme, None
|
||||
hostname = rest[1:closing]
|
||||
remainder = rest[closing + 1:]
|
||||
port = remainder[1:] if remainder.startswith(":") else ""
|
||||
return hostname, scheme, int(port) if port.isdigit() else None
|
||||
|
||||
if rest.count(":") == 1:
|
||||
hostname, _, port = rest.partition(":")
|
||||
if hostname and port.isdigit():
|
||||
return hostname, scheme, int(port)
|
||||
|
||||
return rest, scheme, None
|
||||
|
||||
|
||||
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||
|
||||
@@ -146,6 +181,12 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
so it keeps its category colour instead of flashing red. An open TCP socket
|
||||
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
||||
"""
|
||||
override = str(svc.get("host") or "").strip()
|
||||
override_scheme: str | None = None
|
||||
override_port: int | None = None
|
||||
if override:
|
||||
host, override_scheme, override_port = _parse_override(override)
|
||||
|
||||
if not host or host.startswith("-"):
|
||||
return "unknown"
|
||||
if str(svc.get("protocol", "")).lower() == "udp":
|
||||
@@ -153,20 +194,22 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
|
||||
port = svc.get("port")
|
||||
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||
if port is None:
|
||||
port = override_port
|
||||
|
||||
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
||||
if port is not None and port in _NON_HTTP_PORTS:
|
||||
return "unknown"
|
||||
|
||||
name = str(svc.get("service_name", "")).lower()
|
||||
is_web = port is not None or "http" in name
|
||||
is_web = port is not None or "http" in name or override_scheme is not None
|
||||
if not is_web:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
scheme = "https" if (
|
||||
scheme = override_scheme or ("https" if (
|
||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||
) else "http"
|
||||
) else "http")
|
||||
url_host = _service_host(svc, host)
|
||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||
return "online" if await _http_get(url, verify=False) else "offline"
|
||||
@@ -187,6 +230,13 @@ async def check_services(
|
||||
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||
async with sem:
|
||||
status = await check_service(svc, host)
|
||||
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||
# `host` rides along: several vhosts can share one port on one node, so
|
||||
# port+protocol alone no longer identifies a service on the client side.
|
||||
return {
|
||||
"port": svc.get("port"),
|
||||
"protocol": svc.get("protocol"),
|
||||
"host": svc.get("host"),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""The 3.2.0 → 3.3.0 upgrade of the `nodes` table.
|
||||
|
||||
3.3.0 moved the device facts off `nodes` onto `device_inventory` and dropped the
|
||||
columns. A real 3.2.0 database declares several of them NOT NULL with no
|
||||
server-side default (`status`, `services`, `properties`, `show_hardware`) — the
|
||||
schema here is `create_all`'s output at tag v3.2.0, not a hand-simplified copy,
|
||||
because that constraint is exactly what breaks: the 3.3.0 model no longer writes
|
||||
those columns, so if they survive the upgrade every INSERT fails with
|
||||
``NOT NULL constraint failed: nodes.status`` and approving a device is dead.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
import app.db.database as database
|
||||
import app.services.inventory_sync as inventory_sync
|
||||
|
||||
# `create_all` under v3.2.0. NOT NULL is reproduced exactly where it was.
|
||||
_NODES_320 = (
|
||||
"CREATE TABLE nodes ("
|
||||
"id VARCHAR NOT NULL PRIMARY KEY, type VARCHAR NOT NULL, label VARCHAR NOT NULL, "
|
||||
"design_id VARCHAR, hostname VARCHAR, ip VARCHAR, mac VARCHAR, os VARCHAR, "
|
||||
"status VARCHAR NOT NULL, check_method VARCHAR, check_target VARCHAR, "
|
||||
"services JSON NOT NULL, notes TEXT, pos_x FLOAT NOT NULL, pos_y FLOAT NOT NULL, "
|
||||
"parent_id VARCHAR, container_mode BOOLEAN NOT NULL, custom_colors JSON, "
|
||||
"custom_icon VARCHAR, cpu_count INTEGER, cpu_model VARCHAR, ram_gb FLOAT, "
|
||||
"disk_gb FLOAT, show_hardware BOOLEAN NOT NULL, show_port_numbers BOOLEAN NOT NULL, "
|
||||
"properties JSON NOT NULL, width FLOAT, height FLOAT, "
|
||||
"bottom_handles INTEGER NOT NULL, top_handles INTEGER NOT NULL, "
|
||||
"left_handles INTEGER NOT NULL, right_handles INTEGER NOT NULL, "
|
||||
"ieee_address VARCHAR, last_seen DATETIME, last_scan DATETIME, "
|
||||
"response_time_ms INTEGER, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
|
||||
)
|
||||
|
||||
_DESIGNS_320 = (
|
||||
"CREATE TABLE designs (id VARCHAR NOT NULL PRIMARY KEY, name VARCHAR NOT NULL, "
|
||||
"design_type VARCHAR NOT NULL, icon VARCHAR, created_at DATETIME NOT NULL, "
|
||||
"updated_at DATETIME NOT NULL)"
|
||||
)
|
||||
|
||||
|
||||
def _node_sql(node_id: str, label: str, node_type: str, ip: str | None) -> str:
|
||||
ip_sql = f"'{ip}'" if ip else "NULL"
|
||||
return (
|
||||
"INSERT INTO nodes (id, type, label, design_id, ip, status, services, pos_x, pos_y, "
|
||||
"container_mode, show_hardware, show_port_numbers, properties, bottom_handles, "
|
||||
"top_handles, left_handles, right_handles, created_at, updated_at) VALUES "
|
||||
f"('{node_id}', '{node_type}', '{label}', 'd1', {ip_sql}, 'online', '[]', 0, 0, "
|
||||
"0, 0, 0, '[]', 1, 1, 0, 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_320(tmp_path, monkeypatch):
|
||||
"""A v3.2.0 database, with the module-global engine pointed at it."""
|
||||
db_path = tmp_path / "v320.db"
|
||||
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
monkeypatch.setattr(
|
||||
database, "AsyncSessionLocal", database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
)
|
||||
return db_path, engine
|
||||
|
||||
|
||||
async def _build_320(engine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DESIGNS_320)
|
||||
await conn.exec_driver_sql(_NODES_320)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
|
||||
"VALUES ('d1', 'Network Topology', 'network', 'dashboard', "
|
||||
"'2026-01-01 00:00:00', '2026-01-01 00:00:00')"
|
||||
)
|
||||
await conn.exec_driver_sql(_node_sql("n1", "OpnSense", "firewall", "192.168.1.1"))
|
||||
await conn.exec_driver_sql(_node_sql("n2", "NAS", "nas", "192.168.1.2"))
|
||||
await conn.exec_driver_sql(_node_sql("n3", "a note", "text", None))
|
||||
|
||||
|
||||
async def _insert_node(engine) -> None:
|
||||
"""What approve does: insert a node the way the 3.3.0 model writes one."""
|
||||
from app.db.models import Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
session.add(Node(id="new", type="firewall", label="Approved", design_id="d1"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_upgrade_drops_the_device_columns_and_leaves_nodes_insertable(db_320):
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
|
||||
assert "status" not in cols
|
||||
assert "ip" not in cols
|
||||
assert "device_id" in cols
|
||||
# Every device node linked; the facts moved to the inventory row.
|
||||
linked = (await conn.exec_driver_sql(
|
||||
"SELECT label, device_id FROM nodes ORDER BY id"
|
||||
)).fetchall()
|
||||
assert [row[0] for row in linked] == ["OpnSense", "NAS", "a note"]
|
||||
assert linked[0][1] and linked[1][1]
|
||||
assert linked[2][1] is None # canvas furniture keeps no row
|
||||
ips = (await conn.exec_driver_sql(
|
||||
"SELECT ip FROM device_inventory ORDER BY ip"
|
||||
)).fetchall()
|
||||
assert [row[0] for row in ips] == ["192.168.1.1", "192.168.1.2"]
|
||||
|
||||
await _insert_node(engine)
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_node_the_backfill_cannot_link_still_leaves_nodes_insertable(db_320, monkeypatch):
|
||||
"""The regression: a failed backfill must not brick every later INSERT.
|
||||
|
||||
Before the fix, one unlinkable node kept `nodes.status` NOT NULL while the
|
||||
3.3.0 model stopped writing it, so approving *any* device raised
|
||||
``NOT NULL constraint failed: nodes.status`` (issue #351).
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
|
||||
real_link_facts = inventory_sync.link_facts
|
||||
|
||||
async def link_facts(db, node, facts, **kwargs):
|
||||
if node.id == "n2":
|
||||
raise IntegrityError("boom", None, Exception("UNIQUE constraint failed"))
|
||||
return await real_link_facts(db, node, facts, **kwargs)
|
||||
|
||||
monkeypatch.setattr(inventory_sync, "link_facts", link_facts)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||
cols = {c[1]: c for c in info}
|
||||
# The columns stay — they are the only remaining copy of n2's facts.
|
||||
assert "status" in cols
|
||||
# But nothing is NOT NULL any more, so the model can insert again.
|
||||
assert not any(c[3] for name, c in cols.items() if name in database._LEGACY_NODE_COLUMNS)
|
||||
# Values preserved by the rebuild.
|
||||
kept = (await conn.exec_driver_sql(
|
||||
"SELECT status, ip FROM nodes WHERE id='n2'"
|
||||
)).fetchone()
|
||||
assert kept == ("online", "192.168.1.2")
|
||||
# The node that did link is linked; the failed one is not.
|
||||
linked = dict((await conn.exec_driver_sql(
|
||||
"SELECT id, device_id FROM nodes"
|
||||
)).fetchall())
|
||||
assert linked["n1"] is not None
|
||||
assert linked["n2"] is None
|
||||
|
||||
await _insert_node(engine)
|
||||
|
||||
# A boot where the failure persists changes nothing further: the relax is
|
||||
# a one-off, not a rebuild on every start.
|
||||
await database.init_db()
|
||||
async with check.begin() as conn:
|
||||
kept = (await conn.exec_driver_sql(
|
||||
"SELECT status, ip FROM nodes WHERE id='n2'"
|
||||
)).fetchone()
|
||||
assert kept == ("online", "192.168.1.2")
|
||||
assert (await conn.exec_driver_sql(
|
||||
"SELECT COUNT(*) FROM nodes"
|
||||
)).scalar() == 4 # the three seeded, plus the one just approved
|
||||
|
||||
# A later boot retries the backfill: with the failure gone, n2 links and
|
||||
# the columns finally drop.
|
||||
monkeypatch.setattr(inventory_sync, "link_facts", real_link_facts)
|
||||
await database.init_db()
|
||||
async with check.begin() as conn:
|
||||
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
|
||||
assert "status" not in cols
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
@@ -11,23 +11,54 @@ async def _add_design(db_session, name: str) -> str:
|
||||
return design.id
|
||||
|
||||
|
||||
def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
|
||||
def _node(design_id: str, *, ip=None, ieee=None, mac=None, device_id=None) -> Node:
|
||||
"""A canvas node. Addresses belong to the inventory row it draws, so pass
|
||||
`device_id`, or let `_node_for` mint the row from the addresses."""
|
||||
return Node(
|
||||
id=str(uuid.uuid4()), label="n", type="server", status="online",
|
||||
ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
|
||||
design_id=design_id,
|
||||
id=str(uuid.uuid4()), label="n", type="server", pos_x=0.0, pos_y=0.0,
|
||||
design_id=design_id, device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
async def _node_for(db_session, design_id: str, *, ip=None, ieee=None, mac=None, **device_kwargs) -> Node:
|
||||
"""A node plus the inventory row carrying its addresses, linked.
|
||||
|
||||
Reuses a row that already describes these addresses — one device is one row,
|
||||
so a test placing the same host on a second canvas gets a second node, not a
|
||||
second device.
|
||||
"""
|
||||
from app.services.inventory_sync import find_device_for
|
||||
|
||||
device = await find_device_for(db_session, ip=ip, mac=mac, ieee=ieee)
|
||||
if device is None:
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip=ip, mac=mac, ieee_address=ieee,
|
||||
status="approved", status_live="online", **device_kwargs,
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
node = _node(design_id, device_id=device.id)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
return node
|
||||
|
||||
|
||||
async def _seed_zigbee_pending_pair(db_session):
|
||||
"""Create a coordinator Node + a pending device + a link between them."""
|
||||
from app.db.models import InventoryDeviceLink, Node
|
||||
|
||||
coord_device = InventoryDevice(
|
||||
ieee_address="0xCOORD",
|
||||
friendly_name="Coordinator",
|
||||
suggested_type="zigbee_coordinator",
|
||||
status="approved",
|
||||
)
|
||||
db_session.add(coord_device)
|
||||
await db_session.flush()
|
||||
coord = Node(
|
||||
label="Coordinator",
|
||||
type="zigbee_coordinator",
|
||||
status="unknown",
|
||||
ieee_address="0xCOORD",
|
||||
device_id=coord_device.id,
|
||||
)
|
||||
db_session.add(coord)
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from tests.scan.helpers import _add_design, _node
|
||||
from tests.scan.helpers import _add_design, _node_for
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
|
||||
# A node with no design_id is not "on a canvas".
|
||||
db_session.add(_node(None, ip="192.168.1.100"))
|
||||
await _node_for(db_session, None, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
@@ -57,16 +57,17 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
||||
design = Design(name="d1")
|
||||
db_session.add(design)
|
||||
await db_session.flush()
|
||||
existing = Node(
|
||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
||||
services=[], design_id=design.id,
|
||||
)
|
||||
db_session.add(existing)
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
||||
status="pending", discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
# The node already on this design draws that very row.
|
||||
existing = Node(
|
||||
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=design.id,
|
||||
)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -86,7 +87,7 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
||||
|
||||
# No second node created; device stays pending until the user decides.
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
|
||||
@@ -99,15 +100,15 @@ async def test_approve_device_force_creates_duplicate_ieee(
|
||||
design = Design(name="d1")
|
||||
db_session.add(design)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
||||
services=[], design_id=design.id,
|
||||
))
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
||||
status="pending", discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=design.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -120,7 +121,7 @@ async def test_approve_device_force_creates_duplicate_ieee(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2
|
||||
|
||||
@@ -133,8 +134,7 @@ async def test_approve_device_conflicts_on_existing_ip(
|
||||
silently duplicated: the approve returns 409 with the existing node so the
|
||||
UI can ask the user."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
existing = _node(design, ip="192.168.1.100")
|
||||
db_session.add(existing)
|
||||
existing = await _node_for(db_session, design, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -163,10 +163,8 @@ async def test_approve_device_conflicts_on_existing_mac(
|
||||
):
|
||||
"""MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online",
|
||||
ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
# The node already on this design draws a row carrying that MAC.
|
||||
await _node_for(db_session, design, ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff")
|
||||
|
||||
# pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip.
|
||||
res = await client.post(
|
||||
@@ -188,8 +186,7 @@ async def test_approve_device_conflicts_on_ip_in_comma_list(
|
||||
as. Exact-string matching missed it (issue #258); per-token matching catches
|
||||
the duplicate."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
existing = _node(design, ip="fe80::1, 192.168.1.100")
|
||||
db_session.add(existing)
|
||||
existing = await _node_for(db_session, design, ip="fe80::1, 192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -212,7 +209,7 @@ async def test_approve_device_no_conflict_on_ip_substring(
|
||||
"""The ip guard must match whole addresses, not substrings: a node at
|
||||
10.0.0.40 is not a duplicate of a device at 10.0.0.4."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
db_session.add(_node(design, ip="10.0.0.40"))
|
||||
await _node_for(db_session, design, ip="10.0.0.40")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -230,7 +227,7 @@ async def test_approve_device_force_creates_duplicate(
|
||||
):
|
||||
"""force=True (user confirmed) bypasses the guard and creates the node."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
db_session.add(_node(design, ip="192.168.1.100"))
|
||||
await _node_for(db_session, design, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -251,7 +248,7 @@ async def test_approve_device_allows_same_ip_on_other_design(
|
||||
"""The guard is per-design: the same host on a different canvas is fine."""
|
||||
other = await _add_design(db_session, "Lab")
|
||||
target = await _add_design(db_session, "Home")
|
||||
db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design
|
||||
await _node_for(db_session, other, ip="192.168.1.100") # exists on a DIFFERENT design
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -273,13 +270,16 @@ async def test_approve_device_places_already_approved_on_another_design(
|
||||
other = await _add_design(db_session, "Other")
|
||||
target = await _add_design(db_session, "Network Topology")
|
||||
# Device is on `other` already (its global status is "approved").
|
||||
db_session.add(_node(other, ieee="0x00158d0005292b83"))
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
|
||||
suggested_type="zigbee_enddevice", status="approved",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=other,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -292,7 +292,7 @@ async def test_approve_device_places_already_approved_on_another_design(
|
||||
# A node now exists on the target design too (one per canvas).
|
||||
nodes = (
|
||||
await db_session.execute(
|
||||
select(Node).where(Node.ieee_address == "0x00158d0005292b83")
|
||||
select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0x00158d0005292b83")
|
||||
)
|
||||
).scalars().all()
|
||||
assert {n.design_id for n in nodes} == {other, target}
|
||||
@@ -464,7 +464,7 @@ async def test_bulk_approve_skips_device_already_on_target_design(
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
# First device already sits on the canvas (matched by ip).
|
||||
db_session.add(_node(design, ip="192.168.1.10"))
|
||||
await _node_for(db_session, design, ip="192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -480,9 +480,10 @@ async def test_bulk_approve_skips_device_already_on_target_design(
|
||||
nodes = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
|
||||
).scalars().all()
|
||||
devices = [await db_session.get(InventoryDevice, n.device_id) for n in nodes]
|
||||
# The pre-existing node plus the one newly approved — no duplicate for .10.
|
||||
assert len(nodes) == 2
|
||||
assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
|
||||
assert sorted(d.ip for d in devices) == ["192.168.1.10", "192.168.1.11"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -493,7 +494,7 @@ async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
|
||||
as the plain IPv4 is still recognised as already placed (issue #258)."""
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
|
||||
await _node_for(db_session, design, ip="fe80::1, 192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -515,8 +516,7 @@ async def test_bulk_approve_reports_skipped_devices(
|
||||
(with the existing node id) instead of silently dropping it."""
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
existing = _node(design, ip="192.168.1.10")
|
||||
db_session.add(existing)
|
||||
existing = await _node_for(db_session, design, ip="192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -551,10 +551,14 @@ async def test_approve_device_copies_mac_to_node_and_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||
).scalar_one()
|
||||
assert node.mac == "aa:bb:cc:dd:ee:ff"
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.mac == "aa:bb:cc:dd:ee:ff"
|
||||
mac_props = [p for p in device.properties if p["key"] == "MAC"]
|
||||
assert mac_props == [
|
||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||
]
|
||||
@@ -587,10 +591,14 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
|
||||
).scalar_one()
|
||||
assert node.mac == "11:22:33:44:55:66"
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.mac == "11:22:33:44:55:66"
|
||||
mac_props = [p for p in device.properties if p["key"] == "MAC"]
|
||||
assert mac_props == [
|
||||
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
|
||||
]
|
||||
@@ -607,8 +615,10 @@ async def test_bulk_approve_sets_default_check_method(client: AsyncClient, heade
|
||||
assert res.status_code == 200
|
||||
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
||||
for n in nodes:
|
||||
if n.ip:
|
||||
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
||||
device = await db_session.get(InventoryDevice, n.device_id)
|
||||
assert device is not None
|
||||
if device.ip:
|
||||
assert device.check_method == "ping", f"device {device.id} has no check_method"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -624,7 +634,9 @@ async def test_approve_device_sets_default_check_method(client: AsyncClient, hea
|
||||
assert res.status_code == 200
|
||||
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
||||
assert node is not None
|
||||
assert node.check_method == "ping"
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.check_method == "ping"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -713,11 +725,14 @@ async def test_bulk_approve_targets_requested_design(client, headers, db_session
|
||||
assert res.json()["approved"] == 1
|
||||
|
||||
node = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "zwave-H-2"))
|
||||
).scalar_one()
|
||||
assert node.design_id == active
|
||||
assert node.design_id != first
|
||||
# Z-Wave device → online + Z-Wave property rows, no ICMP check.
|
||||
assert node.status == "online"
|
||||
assert node.check_method == "none"
|
||||
assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
|
||||
# Z-Wave device → online + Z-Wave property rows, no ICMP check. All of that
|
||||
# is the device's, so it is read off the row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.status_live == "online"
|
||||
assert device.check_method == "none"
|
||||
assert {p["key"] for p in device.properties} == {"Z-Wave ID", "Vendor", "Model"}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Editing an inventory row: PATCH /scan/pending/{id} and the widened create.
|
||||
|
||||
The inventory row owns the device facts, so it has to be editable on its own —
|
||||
not only through a canvas node.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
|
||||
async def _seed(db_session, **kwargs) -> InventoryDevice:
|
||||
device = InventoryDevice(
|
||||
ip="192.168.1.10",
|
||||
mac="aa:bb:cc:11:22:33",
|
||||
hostname="nas",
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
suggested_type="nas",
|
||||
status="pending",
|
||||
discovery_source="arp",
|
||||
discovery_sources=["arp"],
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
**kwargs,
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
return device
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_requires_auth(client: AsyncClient, db_session):
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(f"/api/v1/scan/pending/{device.id}", json={"hostname": "x"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_edits_curated_fields(client: AsyncClient, headers, db_session):
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"label": "Big NAS",
|
||||
"type": "nas",
|
||||
"notes": "in the garage rack",
|
||||
"cpu_count": 4,
|
||||
"cpu_model": "N5105",
|
||||
"ram_gb": 16.0,
|
||||
"disk_gb": 4000.0,
|
||||
"show_hardware": True,
|
||||
"check_method": "http",
|
||||
"check_target": "http://192.168.1.10:5000",
|
||||
"properties": [
|
||||
{"key": "Rack", "value": "B2", "icon": None, "visible": True},
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": False},
|
||||
],
|
||||
"services": [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 5000, "protocol": "tcp", "service_name": "http"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["label"] == "Big NAS"
|
||||
assert data["notes"] == "in the garage rack"
|
||||
assert data["cpu_count"] == 4
|
||||
assert data["ram_gb"] == 16.0
|
||||
assert data["show_hardware"] is True
|
||||
assert data["check_method"] == "http"
|
||||
assert len(data["properties"]) == 2
|
||||
assert len(data["services"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_is_partial(client: AsyncClient, headers, db_session):
|
||||
"""Sending one field must not clear the others."""
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"notes": "only this"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["notes"] == "only this"
|
||||
assert data["hostname"] == "nas"
|
||||
assert data["ip"] == "192.168.1.10"
|
||||
assert data["mac"] == "aa:bb:cc:11:22:33"
|
||||
assert data["suggested_type"] == "nas"
|
||||
assert len(data["services"]) == 1
|
||||
assert data["properties"][0]["key"] == "Rack"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_normalizes_mac(client: AsyncClient, headers, db_session):
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"mac": "AA-BB-CC-99-88-77"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["mac"] == "aa:bb:cc:99:88:77"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_leaves_lifecycle_alone(client: AsyncClient, headers, db_session):
|
||||
"""`status` is owned by approve/hide, not by the edit modal."""
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}",
|
||||
headers=headers,
|
||||
json={"label": "x", "status": "approved", "discovery_source": "manual"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["label"] == "x"
|
||||
assert data["status"] == "pending"
|
||||
assert data["discovery_source"] == "arp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_unknown_id(client: AsyncClient, headers):
|
||||
res = await client.patch("/api/v1/scan/pending/nope", headers=headers, json={"notes": "x"})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_accepts_curated_fields(client: AsyncClient, headers):
|
||||
"""A hand-made entry carries everything the modal shows, no PATCH round trip."""
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
headers=headers,
|
||||
json={
|
||||
"hostname": "patch-panel",
|
||||
"os": "n/a",
|
||||
"label": "Patch panel 24p",
|
||||
"type": "switch",
|
||||
"notes": "passive",
|
||||
"services": [{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
"friendly_name": "PP-1",
|
||||
"device_subtype": "passive",
|
||||
"check_method": "none",
|
||||
"discovery_source": "manual",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["label"] == "Patch panel 24p"
|
||||
assert data["type"] == "switch"
|
||||
assert data["os"] == "n/a"
|
||||
assert data["notes"] == "passive"
|
||||
assert data["friendly_name"] == "PP-1"
|
||||
assert data["services"][0]["port"] == 80
|
||||
assert data["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_row_defaults_are_coerced(client: AsyncClient, headers, db_session):
|
||||
"""Rows predating the 3.3.0 columns read back with defaults, not nulls."""
|
||||
device = await _seed(db_session)
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
row = next(d for d in res.json() if d["id"] == device.id)
|
||||
assert row["status_live"] == "unknown"
|
||||
assert row["show_hardware"] is False
|
||||
assert row["label"] is None
|
||||
@@ -31,9 +31,13 @@ async def test_approve_zigbee_device_populates_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
keys = {p["key"]: p["value"] for p in device.properties}
|
||||
assert keys == {
|
||||
"IEEE": "0xABCDEF",
|
||||
"Vendor": "IKEA",
|
||||
@@ -56,14 +60,18 @@ async def test_bulk_approve_zigbee_populates_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
keys = {p["key"]: p["value"] for p in device.properties}
|
||||
assert keys["IEEE"] == "0xABCDEF"
|
||||
assert keys["Vendor"] == "IKEA"
|
||||
assert keys["Model"] == "TRADFRI"
|
||||
assert keys["LQI"] == "180"
|
||||
assert node.check_method == "none"
|
||||
assert device.check_method == "none"
|
||||
|
||||
|
||||
def test_build_mac_property_returns_hidden_row():
|
||||
@@ -133,9 +141,13 @@ async def test_approve_device_does_not_duplicate_mac_property(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||
).scalar_one()
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
mac_props = [p for p in device.properties if p["key"] == "MAC"]
|
||||
assert len(mac_props) == 1
|
||||
# User's visibility choice is preserved.
|
||||
assert mac_props[0]["visible"] is True
|
||||
@@ -328,9 +340,13 @@ async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_ses
|
||||
assert res.status_code == 200
|
||||
|
||||
node = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "zwave-H-9"))
|
||||
).scalar_one()
|
||||
assert node.design_id == active
|
||||
assert node.status == "online"
|
||||
assert node.check_method == "none"
|
||||
assert any(p["key"] == "Z-Wave ID" for p in node.properties)
|
||||
node = (await db_session.execute(select(Node))).scalars().first()
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.status_live == "online"
|
||||
assert device.check_method == "none"
|
||||
assert any(p["key"] == "Z-Wave ID" for p in device.properties)
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
from tests.scan.helpers import _add_design, _node
|
||||
from tests.scan.helpers import _add_design, _node_for
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -55,7 +55,7 @@ async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session
|
||||
# Node.ip holds several comma-separated addresses (IPv6 added first). The
|
||||
# device scanned as the plain IPv4 must still correlate (issue #258).
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
db_session.add(_node(d1, ip="fe80::1, 192.168.1.100"))
|
||||
await _node_for(db_session, d1, ip="fe80::1, 192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
@@ -67,7 +67,7 @@ async def test_canvas_count_correlates_by_mac(client, headers, db_session, pendi
|
||||
# Node's ip differs entirely (user edited it) but the MAC still matches:
|
||||
# the device is on the canvas (issue #258, MAC is the stable identifier).
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
db_session.add(_node(d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff"))
|
||||
await _node_for(db_session, d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff")
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
@@ -79,8 +79,8 @@ async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_se
|
||||
# Same IP placed on two different canvases → canvas_count == 2.
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
d2 = await _add_design(db_session, "Lab")
|
||||
db_session.add(_node(d1, ip="192.168.1.100"))
|
||||
db_session.add(_node(d2, ip="192.168.1.100"))
|
||||
await _node_for(db_session, d1, ip="192.168.1.100")
|
||||
await _node_for(db_session, d2, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
@@ -97,7 +97,7 @@ async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
|
||||
)
|
||||
db_session.add(device)
|
||||
d1 = await _add_design(db_session, "Zigbee")
|
||||
db_session.add(_node(d1, ieee="0x00124b001"))
|
||||
await _node_for(db_session, d1, ieee="0x00124b001")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
@@ -120,10 +120,13 @@ async def test_pending_device_without_node_has_null_node_timestamps(client, head
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
node = _node(d1, ip="192.168.1.100")
|
||||
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
|
||||
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
|
||||
db_session.add(node)
|
||||
node = await _node_for(db_session, d1, ip="192.168.1.100")
|
||||
# When the scanner last saw the device, and when it last answered, are facts
|
||||
# about the device — they live on its row, not on any one drawing of it.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
device.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
|
||||
device.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||
@@ -139,18 +142,21 @@ async def test_node_timestamps_aggregate_across_matches(client, headers, db_sess
|
||||
# last_scan takes the NEWEST.
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
d2 = await _add_design(db_session, "Lab")
|
||||
older = _node(d1, ip="192.168.1.100")
|
||||
older = await _node_for(db_session, d1, ip="192.168.1.100")
|
||||
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
|
||||
newer = _node(d2, ip="192.168.1.100")
|
||||
newer = await _node_for(db_session, d2, ip="192.168.1.100")
|
||||
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
|
||||
newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
# Both nodes draw the one row, which carries the scan observation.
|
||||
assert older.device_id == newer.device_id
|
||||
device = await db_session.get(InventoryDevice, older.device_id)
|
||||
assert device is not None
|
||||
device.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
db_session.add_all([older, newer])
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||
assert data["node_created_at"].startswith("2026-01-01") # oldest
|
||||
assert data["node_last_scan"].startswith("2026-06-01") # newest
|
||||
assert data["node_created_at"].startswith("2026-01-01") # oldest node
|
||||
assert data["node_last_scan"].startswith("2026-06-01") # off the device row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -291,6 +297,46 @@ async def test_create_pending_keeps_a_missing_mac_null(client: AsyncClient, head
|
||||
assert res.json()["mac"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_unhides_the_row_it_merges_into(client: AsyncClient, headers, db_session):
|
||||
"""Adding a device by hand outranks an earlier hide of the same host.
|
||||
|
||||
One device is one row, hidden ones included — but merging into a hidden row
|
||||
and leaving it hidden answers 201 while nothing appears in the inventory, so
|
||||
the add reads as a no-op.
|
||||
"""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="192.168.1.50", status="hidden"))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"ip": "192.168.1.50", "hostname": "printer", "discovery_source": "manual"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["id"] == "d-1"
|
||||
assert res.json()["status"] == "pending"
|
||||
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == ["d-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_leaves_an_approved_row_approved(client: AsyncClient, headers, db_session):
|
||||
"""Only `hidden` is lifted — a merge must not walk a device back down the
|
||||
lifecycle and re-queue something already on a canvas."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="192.168.1.51", status="approved"))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"ip": "192.168.1.51", "hostname": "nas", "discovery_source": "manual"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_removes_one_entry(client: AsyncClient, headers, pending_device):
|
||||
# The rack canvas drops the placeholder it created for a plate once that
|
||||
|
||||
@@ -131,16 +131,6 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
||||
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
||||
"""Pending devices whose IP is already on a canvas are NOT purged — they stay
|
||||
in the inventory and are surfaced with an "In N canvas" badge."""
|
||||
node = Node(
|
||||
id=str(uuid.uuid4()),
|
||||
label="Existing Server",
|
||||
type="server",
|
||||
ip="192.168.1.50",
|
||||
status="online",
|
||||
services=[],
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
stale = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
@@ -151,8 +141,17 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.add(stale)
|
||||
await db_session.flush()
|
||||
# A canvas already draws that device.
|
||||
db_session.add(Node(
|
||||
id=str(uuid.uuid4()),
|
||||
label="Existing Server",
|
||||
type="server",
|
||||
device_id=stale.id,
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
@@ -174,19 +173,23 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
||||
"""A scanned IP that already exists as a canvas Node still produces a pending
|
||||
device (no longer suppressed)."""
|
||||
node = Node(
|
||||
"""A scanned IP already drawn on a canvas refreshes the one inventory row.
|
||||
|
||||
The device is never suppressed, and never duplicated: the node draws that
|
||||
row, so a second row for the same host would split the device in two.
|
||||
"""
|
||||
existing = InventoryDevice(id=str(uuid.uuid4()), ip="192.168.1.50", status="approved")
|
||||
db_session.add(existing)
|
||||
await db_session.flush()
|
||||
existing_id = existing.id
|
||||
db_session.add(Node(
|
||||
id=str(uuid.uuid4()),
|
||||
label="Existing Server",
|
||||
type="server",
|
||||
ip="192.168.1.50",
|
||||
status="online",
|
||||
services=[],
|
||||
device_id=existing.id,
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
db_session.add(node)
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
@@ -200,12 +203,16 @@ async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
assert device.status == "pending"
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == existing_id
|
||||
# The scan refreshed it; it did not reset the lifecycle.
|
||||
assert rows[0].status == "approved"
|
||||
assert rows[0].hostname == MOCK_HOST["hostname"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -124,6 +124,41 @@ def test_suggest_node_type_priority_proxmox_over_server():
|
||||
assert result == "proxmox"
|
||||
|
||||
|
||||
# A port-agnostic signature: only an HTTP probe can ever match it.
|
||||
KVM_SIGNATURE = {
|
||||
"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "PiKVM",
|
||||
"service_name": "PiKVM", "icon": "monitorcog", "category": "kvm",
|
||||
"suggested_node_type": "kvm",
|
||||
}
|
||||
|
||||
|
||||
def test_suggest_node_type_kvm_from_http_title():
|
||||
# An IP-KVM answers on 443 like any web app; only the probed title identifies it.
|
||||
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
|
||||
result = suggest_node_type([
|
||||
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
|
||||
])
|
||||
assert result == "kvm"
|
||||
|
||||
|
||||
def test_suggest_node_type_priority_kvm_over_server():
|
||||
# The same host also answers on 80 (→ server); the KVM signal outranks it.
|
||||
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
|
||||
result = suggest_node_type([
|
||||
{"port": 80, "protocol": "tcp"},
|
||||
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
|
||||
])
|
||||
assert result == "kvm"
|
||||
|
||||
|
||||
def test_suggest_node_type_ignores_port_agnostic_sig_without_probe():
|
||||
# No http_signals (probe disabled) → port-agnostic entries can't match and the
|
||||
# port-only guess stands, unchanged from pre-probe behaviour.
|
||||
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
|
||||
result = suggest_node_type([{"port": 443, "protocol": "tcp"}])
|
||||
assert result == "server"
|
||||
|
||||
|
||||
def test_suggest_node_type_camera_from_rtsp_port():
|
||||
# RTSP port 554 → camera (via _PORT_TYPE_HINTS)
|
||||
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
"""The node ↔ Device Inventory link: matching, merging, and the 3.3.0 backfill.
|
||||
|
||||
The inventory row owns the device facts; a node owns how the device is drawn.
|
||||
These tests pin the rules that make one device end up as one row even when it
|
||||
was drawn on several canvases.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from app.services.inventory_sync import (
|
||||
backfill_node_devices,
|
||||
changed_facts,
|
||||
find_device_for,
|
||||
link_facts,
|
||||
merge_properties,
|
||||
merge_services,
|
||||
)
|
||||
|
||||
|
||||
def _now(offset_minutes: int = 0) -> datetime:
|
||||
return datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=offset_minutes)
|
||||
|
||||
|
||||
async def _design(db_session, name="Net") -> str:
|
||||
design = Design(id=str(uuid.uuid4()), name=name)
|
||||
db_session.add(design)
|
||||
await db_session.commit()
|
||||
return design.id
|
||||
|
||||
|
||||
def _node(design_id: str, **kwargs) -> Node:
|
||||
payload = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"label": "n",
|
||||
"type": "server",
|
||||
"pos_x": 0.0,
|
||||
"pos_y": 0.0,
|
||||
"design_id": design_id,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
return Node(**payload)
|
||||
|
||||
|
||||
# --- pure merge rules -------------------------------------------------------
|
||||
|
||||
|
||||
class TestMergeRules:
|
||||
def test_properties_union_on_key_with_incoming_winning(self):
|
||||
base = [{"key": "Rack", "value": "A1", "icon": None, "visible": True}]
|
||||
incoming = [
|
||||
{"key": "rack", "value": "B2", "icon": None, "visible": False},
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": True},
|
||||
]
|
||||
out = merge_properties(base, incoming)
|
||||
assert [p["value"] for p in out] == ["B2", "me"]
|
||||
# Existing keys keep their position — a user's ordering survives.
|
||||
assert out[0]["key"] == "Rack"
|
||||
assert out[0]["visible"] is False
|
||||
|
||||
def test_properties_keep_established_value_when_incoming_is_blank(self):
|
||||
out = merge_properties(
|
||||
[{"key": "Rack", "value": "A1", "icon": "Server", "visible": True}],
|
||||
[{"key": "Rack", "value": "", "icon": None, "visible": True}],
|
||||
)
|
||||
assert out[0]["value"] == "A1"
|
||||
assert out[0]["icon"] == "Server"
|
||||
|
||||
def test_services_union_on_port_protocol_and_name(self):
|
||||
base = [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
|
||||
incoming = [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "SSH", "icon": "Terminal"},
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||
]
|
||||
out = merge_services(base, incoming)
|
||||
assert len(out) == 2
|
||||
assert out[0]["icon"] == "Terminal"
|
||||
assert out[1]["port"] == 80
|
||||
|
||||
|
||||
class TestChangedFacts:
|
||||
"""What a save is allowed to write: its edit, not its whole snapshot."""
|
||||
|
||||
def test_an_unchanged_snapshot_writes_nothing(self):
|
||||
device = InventoryDevice(
|
||||
id="d-1",
|
||||
label="NAS",
|
||||
type="nas",
|
||||
hostname="nas.lan",
|
||||
ip="10.0.0.5",
|
||||
notes="in the garage",
|
||||
status_live="online",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
)
|
||||
facts = {
|
||||
"label": "NAS",
|
||||
"type": "nas",
|
||||
"hostname": "nas.lan",
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "in the garage",
|
||||
"status": "online",
|
||||
"properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
}
|
||||
assert changed_facts(device, facts) == {}
|
||||
|
||||
def test_only_the_edited_field_survives(self):
|
||||
device = InventoryDevice(id="d-1", hostname="nas.lan", ip="10.0.0.5", notes="old")
|
||||
facts = {"hostname": "nas.lan", "ip": "10.0.0.5", "notes": "new"}
|
||||
assert changed_facts(device, facts) == {"notes": "new"}
|
||||
|
||||
def test_a_blank_incoming_value_is_not_a_change(self):
|
||||
# A blank never clears an established value, so it is not an edit either.
|
||||
device = InventoryDevice(id="d-1", hostname="nas.lan", notes="keep me")
|
||||
assert changed_facts(device, {"hostname": "", "notes": None}) == {}
|
||||
|
||||
def test_a_changed_list_is_sent_whole(self):
|
||||
device = InventoryDevice(
|
||||
id="d-1",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
)
|
||||
facts = {
|
||||
"properties": [],
|
||||
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
}
|
||||
# Replace semantics need the full list, and the untouched one stays out.
|
||||
assert changed_facts(device, facts) == {"properties": []}
|
||||
|
||||
def test_live_status_only_fills_a_row_never_checked(self):
|
||||
unknown = InventoryDevice(id="d-1", status_live="unknown")
|
||||
assert changed_facts(unknown, {"status": "online"}) == {"status": "online"}
|
||||
checked = InventoryDevice(id="d-2", status_live="offline")
|
||||
# The status checker owns reachability; a stale canvas must not reset it.
|
||||
assert changed_facts(checked, {"status": "online"}) == {}
|
||||
|
||||
|
||||
# --- matching ---------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindDeviceFor:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_ieee_over_ip_and_mac(self, db_session):
|
||||
by_ip = InventoryDevice(id="d-ip", ip="10.0.0.5")
|
||||
by_ieee = InventoryDevice(id="d-ieee", ieee_address="0xAAA")
|
||||
db_session.add_all([by_ip, by_ieee])
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee="0xAAA")
|
||||
assert found is not None and found.id == "d-ieee"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_one_address_out_of_a_comma_list(self, db_session):
|
||||
device = InventoryDevice(id="d-1", ip="10.0.0.5, fd00::1")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(db_session, ip="fd00::1", mac=None, ieee=None)
|
||||
assert found is not None and found.id == "d-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_match_a_longer_address_with_the_same_prefix(self, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.40"))
|
||||
await db_session.commit()
|
||||
|
||||
assert await find_device_for(db_session, ip="10.0.0.4", mac=None, ieee=None) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_a_hidden_row_rather_than_minting_a_second(self, db_session):
|
||||
db_session.add(InventoryDevice(id="d-hidden", ip="10.0.0.5", status="hidden"))
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee=None)
|
||||
assert found is not None and found.id == "d-hidden"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_an_ieee_written_in_another_case(self, db_session):
|
||||
"""One radio, one row: `ieee_address` is UNIQUE, so a case-sensitive
|
||||
match here would mint a second row the index then refuses."""
|
||||
db_session.add(InventoryDevice(id="d-1", ieee_address="0x00124b0022334455"))
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(
|
||||
db_session, ip=None, mac=None, ieee="0x00124B0022334455"
|
||||
)
|
||||
assert found is not None and found.id == "d-1"
|
||||
|
||||
|
||||
class TestIeeeCollisions:
|
||||
"""`device_inventory.ieee_address` is UNIQUE — a merge must never duplicate it."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_address_another_row_owns_is_left_where_it_is(self, db_session):
|
||||
"""The node is already linked, so identity is settled — the IEEE is not
|
||||
a reason to move it, and writing it here would violate the index."""
|
||||
design = await _design(db_session)
|
||||
db_session.add_all([
|
||||
InventoryDevice(id="d-radio", ieee_address="0xAAA"),
|
||||
InventoryDevice(id="d-host", ip="10.0.0.5"),
|
||||
])
|
||||
node = _node(design, device_id="d-host")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.id == "d-host"
|
||||
assert device.ieee_address is None
|
||||
radio = await db_session.get(InventoryDevice, "d-radio")
|
||||
assert radio is not None and radio.ieee_address == "0xAAA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_row_that_already_holds_it_keeps_writing_it(self, db_session):
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", ieee_address="0xAAA"))
|
||||
node = _node(design)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.ieee_address == "0xAAA"
|
||||
|
||||
|
||||
# --- the backfill -----------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackfill:
|
||||
"""The 3.3.0 migration path.
|
||||
|
||||
A pre-3.3.0 database still has the device columns on `nodes`; the backfill
|
||||
reads them with raw SQL (the model no longer declares them) and folds each
|
||||
node's view into an inventory row. These tests recreate that shape.
|
||||
"""
|
||||
|
||||
async def _legacy_nodes_table(self, db_session) -> None:
|
||||
"""Re-add the pre-3.3.0 device columns to `nodes`."""
|
||||
for column, sql_type in (
|
||||
("hostname", "VARCHAR"), ("ip", "VARCHAR"), ("mac", "VARCHAR"), ("os", "VARCHAR"),
|
||||
("status", "VARCHAR"), ("check_method", "VARCHAR"), ("check_target", "VARCHAR"),
|
||||
("services", "JSON"), ("notes", "TEXT"), ("cpu_count", "INTEGER"),
|
||||
("cpu_model", "VARCHAR"), ("ram_gb", "FLOAT"), ("disk_gb", "FLOAT"),
|
||||
("show_hardware", "BOOLEAN"), ("properties", "JSON"), ("ieee_address", "VARCHAR"),
|
||||
("last_seen", "DATETIME"), ("last_scan", "DATETIME"), ("response_time_ms", "INTEGER"),
|
||||
):
|
||||
await db_session.execute(text(f"ALTER TABLE nodes ADD COLUMN {column} {sql_type}"))
|
||||
await db_session.commit()
|
||||
|
||||
async def _legacy_node(self, db_session, design_id: str, *, updated_at=None, **facts) -> str:
|
||||
node_id = str(uuid.uuid4())
|
||||
columns = {
|
||||
"id": node_id,
|
||||
"label": facts.pop("label", "n"),
|
||||
"type": facts.pop("type", "server"),
|
||||
"design_id": design_id,
|
||||
"pos_x": 0.0,
|
||||
"pos_y": 0.0,
|
||||
"container_mode": 0,
|
||||
"show_port_numbers": 0,
|
||||
"bottom_handles": 1,
|
||||
"top_handles": 1,
|
||||
"left_handles": 0,
|
||||
"right_handles": 0,
|
||||
"created_at": _now(0),
|
||||
"updated_at": updated_at or _now(0),
|
||||
}
|
||||
for key in ("services", "properties"):
|
||||
if key in facts:
|
||||
facts[key] = json.dumps(facts[key])
|
||||
columns.update(facts)
|
||||
names = ", ".join(columns)
|
||||
binds = ", ".join(f":{c}" for c in columns)
|
||||
await db_session.execute(text(f"INSERT INTO nodes ({names}) VALUES ({binds})"), columns)
|
||||
await db_session.commit()
|
||||
return node_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_nothing_when_the_columns_are_already_gone(self, db_session):
|
||||
"""A second boot: there is no legacy data left to read."""
|
||||
design = await _design(db_session)
|
||||
db_session.add(_node(design))
|
||||
await db_session.commit()
|
||||
|
||||
assert await backfill_node_devices(db_session) == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_links_a_node_to_its_existing_row(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(db_session, design, ip="10.0.0.5", label="NAS")
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 0, "merged": 1, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None and node.device_id == "d-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_a_row_for_a_node_no_scan_ever_saw(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, label="Dumb switch", type="switch", notes="under the desk"
|
||||
)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.label == "Dumb switch"
|
||||
assert device.type == "switch"
|
||||
assert device.notes == "under the desk"
|
||||
assert device.status == "approved"
|
||||
assert device.discovery_sources == ["canvas"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_canvases_one_device_merge_into_one_row(self, db_session):
|
||||
"""The whole point: the same host drawn twice becomes one inventory row."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design_a = await _design(db_session, "A")
|
||||
design_b = await _design(db_session, "B")
|
||||
older = await self._legacy_node(
|
||||
db_session, design_a,
|
||||
ip="10.0.0.5", label="nas-old", hostname="nas.lan", notes="older note",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
updated_at=_now(0),
|
||||
)
|
||||
newer = await self._legacy_node(
|
||||
db_session, design_b,
|
||||
ip="10.0.0.5", label="nas-new", os="TrueNAS",
|
||||
properties=[{"key": "Owner", "value": "me", "icon": None, "visible": True}],
|
||||
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
updated_at=_now(30),
|
||||
)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 2
|
||||
node_a = await db_session.get(Node, older)
|
||||
node_b = await db_session.get(Node, newer)
|
||||
assert node_a is not None and node_b is not None
|
||||
assert node_a.device_id == node_b.device_id
|
||||
device = await db_session.get(InventoryDevice, node_a.device_id)
|
||||
assert device is not None
|
||||
# Scalars: most recent edit wins, blanks never wipe an established value.
|
||||
assert device.label == "nas-new"
|
||||
assert device.hostname == "nas.lan"
|
||||
assert device.os == "TrueNAS"
|
||||
assert device.notes == "older note"
|
||||
# Cumulative fields: union of both canvases.
|
||||
assert {p["key"] for p in device.properties} == {"Rack", "Owner"}
|
||||
assert {s["port"] for s in device.services} == {22, 80}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_canvas_furniture_alone(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
for kind in ("group", "groupRect", "text"):
|
||||
await self._legacy_node(db_session, design, type=kind, label=kind)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 0
|
||||
assert (await db_session.execute(select(InventoryDevice))).scalars().all() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_a_no_op_on_a_second_run(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.5")
|
||||
|
||||
first = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
second = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert first["linked"] == 1
|
||||
assert second == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
# --- routes -----------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoutesKeepTheLinkInStep:
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_a_node_joins_the_matching_row(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", os="TrueNAS"))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
body = res.json()
|
||||
assert body["device_id"] == "d-1"
|
||||
# Hydrated from the row: the node never carried an OS.
|
||||
assert body["os"] == "TrueNAS"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_furniture_creates_no_row(self, client: AsyncClient, headers, db_session):
|
||||
res = await client.post(
|
||||
"/api/v1/nodes", json={"type": "groupRect", "label": "Zone"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["device_id"] is None
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert rows == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editing_a_node_writes_through_to_the_row(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_id}",
|
||||
json={"hostname": "nas2.lan", "notes": "moved to the garage"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.hostname == "nas2.lan"
|
||||
assert device.notes == "moved to the garage"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patching_one_list_leaves_the_other_alone(self, client: AsyncClient, headers, db_session):
|
||||
"""Replace applies list by list — an unsent list is not blanked."""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
# properties only: services must survive untouched.
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_id}",
|
||||
json={"properties": [{"key": "Owner", "value": "me", "icon": None, "visible": True}]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert [p["key"] for p in device.properties] == ["Owner"]
|
||||
assert [s["port"] for s in device.services] == [22]
|
||||
|
||||
# services only: the properties just written must survive in turn.
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_id}",
|
||||
json={"services": [{"port": 443, "protocol": "tcp", "service_name": "https"}]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
await db_session.refresh(device)
|
||||
assert [s["port"] for s in device.services] == [443]
|
||||
assert [p["key"] for p in device.properties] == ["Owner"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patching_a_list_empty_still_clears_it(self, client: AsyncClient, headers, db_session):
|
||||
"""An explicit empty list is a deletion, not an omission."""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.properties == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_canvas_save_does_not_revert_an_inventory_edit(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Regression: a save carries this canvas' edit, not its stale snapshot.
|
||||
|
||||
The canvas payload holds a full copy of the device, hydrated when it
|
||||
loaded. Editing the device elsewhere and then saving the still-open
|
||||
canvas — for nothing but a moved node — must not roll the row back.
|
||||
"""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
label="NAS",
|
||||
type="nas",
|
||||
notes="old note",
|
||||
status="approved",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
|
||||
# What the canvas loaded and still holds in memory.
|
||||
stale = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "old note",
|
||||
"properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
}
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"design_id": design_id, "nodes": [stale], "edges": [], "viewport": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# Meanwhile, in the Device Inventory modal.
|
||||
res = await client.patch(
|
||||
"/api/v1/scan/pending/d-1",
|
||||
json={
|
||||
"notes": "moved to the loft",
|
||||
"properties": [
|
||||
{"key": "Rack", "value": "A1", "icon": None, "visible": True},
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": True},
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# The canvas saves again — only the node position changed, so it claims
|
||||
# no device edit at all.
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [{**stale, "pos_x": 240, "changed_facts": []}],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.notes == "moved to the loft"
|
||||
assert [p["key"] for p in device.properties] == ["Rack", "Owner"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_canvas_save_still_writes_what_that_canvas_changed(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""The narrowing must not cost the write-through: an edit still lands."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", label="NAS", type="nas", notes="old note"))
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
node = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "old note",
|
||||
}
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"design_id": design_id, "nodes": [node], "edges": [], "viewport": {}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [
|
||||
{
|
||||
**node,
|
||||
"notes": "edited on the canvas",
|
||||
"hostname": "nas.lan",
|
||||
"changed_facts": ["notes", "hostname"],
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.notes == "edited on the canvas"
|
||||
assert device.hostname == "nas.lan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_edit_writes_without_dragging_the_rest_of_the_snapshot(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""One edited fact lands; the stale fields beside it stay out of the write."""
|
||||
db_session.add(
|
||||
InventoryDevice(id="d-1", ip="10.0.0.5", label="NAS", type="nas", notes="old note")
|
||||
)
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
node = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "old note",
|
||||
}
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"design_id": design_id, "nodes": [node], "edges": [], "viewport": {}},
|
||||
headers=headers,
|
||||
)
|
||||
await client.patch("/api/v1/scan/pending/d-1", json={"notes": "moved to the loft"}, headers=headers)
|
||||
|
||||
# The canvas renames the node; its `notes` copy is stale but unedited.
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [{**node, "label": "Big NAS", "changed_facts": ["label"]}],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.label == "Big NAS"
|
||||
assert device.notes == "moved to the loft"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_client_that_tracks_no_changes_still_writes_its_facts(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Backward compatible: an import or older client omits the field entirely."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", notes="old note"))
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "from the import",
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.notes == "from the import"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_second_canvas_sees_the_first_canvas_edit(self, client: AsyncClient, headers, db_session):
|
||||
"""No per-node overrides: one device, one set of facts, every canvas."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
|
||||
await db_session.commit()
|
||||
design_a = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
design_b = (await client.post("/api/v1/designs", json={"name": "B"}, headers=headers)).json()["id"]
|
||||
node_a = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_a, "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_b, "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{node_a}", json={"ip": "10.0.0.9"}, headers=headers)
|
||||
|
||||
canvas_b = (await client.get(f"/api/v1/canvas?design_id={design_b}", headers=headers)).json()
|
||||
assert canvas_b["nodes"][0]["ip"] == "10.0.0.9"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_wire_shape_the_mcp_server_reads_is_unchanged(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""`mcp/app/tools.py` reads the facts flat on the node — keep it that way.
|
||||
|
||||
The MCP server is a thin proxy over these routes: `_slim_canvas` keeps
|
||||
`NODE_KEEP`, `_slim_node_summary` keeps label/type/status, and `get_node`
|
||||
looks a node up by label. None of it knows the inventory row exists, so
|
||||
the split must not show on the wire.
|
||||
"""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
hostname="nas.lan",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="TrueNAS",
|
||||
notes="in the garage",
|
||||
cpu_count=8,
|
||||
cpu_model="Xeon",
|
||||
ram_gb=32.0,
|
||||
disk_gb=4000.0,
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
status_live="online",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "Net"}, headers=headers)).json()["id"]
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "nas", "label": "NAS", "ip": "10.0.0.5",
|
||||
"design_id": design_id, "force": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
# mcp `_slim_canvas` NODE_KEEP, plus what `_slim_node_summary` reads.
|
||||
expected = {
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"ip": "10.0.0.5",
|
||||
"hostname": "nas.lan",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"os": "TrueNAS",
|
||||
"status": "online",
|
||||
"notes": "in the garage",
|
||||
"cpu_count": 8,
|
||||
"cpu_model": "Xeon",
|
||||
"ram_gb": 32.0,
|
||||
"disk_gb": 4000.0,
|
||||
}
|
||||
for source in (
|
||||
created,
|
||||
(await client.get(f"/api/v1/nodes/{created['id']}", headers=headers)).json(),
|
||||
(await client.get("/api/v1/nodes", headers=headers)).json()[0],
|
||||
(await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0],
|
||||
# mcp `get_node` falls back to a label lookup when given no id.
|
||||
(await client.get("/api/v1/nodes?label=NAS", headers=headers)).json()[0],
|
||||
):
|
||||
assert {k: source[k] for k in expected} == expected
|
||||
assert source["services"] == [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
|
||||
assert source["properties"] == [
|
||||
{"key": "Rack", "value": "A1", "icon": None, "visible": True}
|
||||
]
|
||||
assert "parent_id" in source
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_a_node_keeps_the_device(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
assert (await client.delete(f"/api/v1/nodes/{node_id}", headers=headers)).status_code == 204
|
||||
assert await db_session.get(InventoryDevice, "d-1") is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approving_a_device_links_instead_of_copying(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(
|
||||
InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", suggested_type="nas", status="pending")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/d-1/approve",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "status": "unknown"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = await db_session.get(Node, res.json()["node_id"])
|
||||
assert node is not None and node.device_id == "d-1"
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe)."""
|
||||
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe).
|
||||
|
||||
Identity is the device link now: two nodes are duplicates when they draw the
|
||||
same `device_inventory` row on the same design. The device facts live on that
|
||||
row, so nothing has to be merged out of the extras before they go — only edges
|
||||
and parent links are re-pointed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Design, Edge, Node
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
from app.db.models import Design, Edge, InventoryDevice, Node
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
|
||||
|
||||
async def _design(db, name="d1"):
|
||||
@@ -14,61 +20,80 @@ async def _design(db, name="d1"):
|
||||
return d
|
||||
|
||||
|
||||
async def _device(db, ieee="0xAAA", **kwargs):
|
||||
device = InventoryDevice(ieee_address=ieee, status="approved", **kwargs)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return device
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collapses_same_ieee_same_design(db_session):
|
||||
async def test_collapses_same_device_same_design(db_session):
|
||||
d = await _design(db_session)
|
||||
device = await _device(db_session)
|
||||
keep = Node(
|
||||
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
||||
ieee_address="0xAAA", properties=[{"key": "IEEE", "value": "0xAAA", "visible": True}],
|
||||
pos_x=100, pos_y=200,
|
||||
device_id=device.id, pos_x=100, pos_y=200,
|
||||
)
|
||||
db_session.add(keep)
|
||||
await db_session.flush()
|
||||
dup = Node(
|
||||
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
||||
ieee_address="0xAAA", ip="10.0.0.5",
|
||||
properties=[{"key": "LQI", "value": "88", "visible": False}],
|
||||
)
|
||||
dup = Node(label="Sensor", type="zigbee_enddevice", design_id=d.id, device_id=device.id)
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_ieee(db_session)
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 1
|
||||
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xAAA"))).scalars().all()
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
survivor = nodes[0]
|
||||
assert survivor.id == keep.id # oldest kept
|
||||
assert survivor.pos_x == 100 # canvas position preserved
|
||||
assert survivor.ip == "10.0.0.5" # missing field filled from dup
|
||||
keys = {p["key"] for p in survivor.properties}
|
||||
assert keys == {"IEEE", "LQI"} # properties merged
|
||||
# The device facts were never on the node, so none can be lost here.
|
||||
assert await db_session.get(InventoryDevice, device.id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_same_ieee_across_designs(db_session):
|
||||
async def test_preserves_same_device_across_designs(db_session):
|
||||
"""Same device on two canvases is valid — must NOT be merged."""
|
||||
d1 = await _design(db_session, "d1")
|
||||
d2 = await _design(db_session, "d2")
|
||||
device = await _device(db_session, ieee="0xBBB")
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, ieee_address="0xBBB"))
|
||||
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, device_id=device.id))
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_ieee(db_session)
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 0
|
||||
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xBBB"))).scalars().all()
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_furniture_alone(db_session):
|
||||
"""Zones and text carry no device link, so they are never duplicates."""
|
||||
d = await _design(db_session)
|
||||
for _ in range(2):
|
||||
db_session.add(Node(label="Zone", type="groupRect", design_id=d.id))
|
||||
await db_session.flush()
|
||||
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repoints_edges_and_drops_dupes(db_session):
|
||||
d = await _design(db_session)
|
||||
keep = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
||||
dup = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
||||
device = await _device(db_session, ieee="0xCCC")
|
||||
keep = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
||||
other = Node(label="B", type="server", design_id=d.id)
|
||||
db_session.add_all([keep, other])
|
||||
await db_session.flush()
|
||||
dup = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
|
||||
@@ -80,7 +105,7 @@ async def test_repoints_edges_and_drops_dupes(db_session):
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_ieee(db_session)
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
@@ -93,17 +118,18 @@ async def test_repoints_edges_and_drops_dupes(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_repoints_child_parent(db_session):
|
||||
d = await _design(db_session)
|
||||
keep = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
||||
dup = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
||||
device = await _device(db_session, ieee="0xDDD")
|
||||
keep = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
||||
db_session.add(keep)
|
||||
await db_session.flush()
|
||||
dup = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
child = Node(label="VM", type="vm", design_id=d.id, parent_id=dup.id)
|
||||
db_session.add(child)
|
||||
await db_session.flush()
|
||||
|
||||
await dedupe_nodes_by_ieee(db_session)
|
||||
await dedupe_nodes_by_device(db_session)
|
||||
await db_session.refresh(child)
|
||||
assert child.parent_id == keep.id
|
||||
|
||||
@@ -111,7 +137,8 @@ async def test_repoints_child_parent(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_noop_when_unique(db_session):
|
||||
d = await _design(db_session)
|
||||
db_session.add(Node(label="X", type="server", design_id=d.id, ieee_address="0xEEE"))
|
||||
device = await _device(db_session, ieee="0xEEE")
|
||||
db_session.add(Node(label="X", type="server", design_id=d.id, device_id=device.id))
|
||||
await db_session.flush()
|
||||
assert await dedupe_nodes_by_ieee(db_session) == 0
|
||||
assert await dedupe_nodes_by_ieee(db_session) == 0
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
|
||||
@@ -222,25 +222,33 @@ async def test_persist_creates_pending(db_session) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
|
||||
# A device previously found by an IP scan (no ieee, no specs).
|
||||
# The scanned host: one inventory row, drawn by one node.
|
||||
scanned_device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="10.0.0.5", suggested_type="generic",
|
||||
status="approved", discovery_source="arp", discovery_sources=["arp"],
|
||||
)
|
||||
db_session.add(scanned_device)
|
||||
await db_session.flush()
|
||||
scanned = Node(
|
||||
id=str(uuid.uuid4()), type="generic", label="10.0.0.5",
|
||||
ip="10.0.0.5", status="online", pos_x=0, pos_y=0,
|
||||
device_id=scanned_device.id, pos_x=0, pos_y=0,
|
||||
)
|
||||
db_session.add(scanned)
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
|
||||
# No duplicate node; identity + specs merged onto the existing one.
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ip == "10.0.0.5"))).scalars().all()
|
||||
# No duplicate node, and no second row: the import merged into the row the
|
||||
# node already draws.
|
||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
merged = nodes[0]
|
||||
merged = await db_session.get(InventoryDevice, nodes[0].device_id)
|
||||
assert merged is not None
|
||||
assert merged.ieee_address == "pve-pve1-101"
|
||||
assert merged.cpu_count == 2
|
||||
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
|
||||
# Inventory row exists as approved (already on canvas).
|
||||
inv = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
# Still approved — it is on a canvas.
|
||||
assert merged.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -290,17 +298,17 @@ async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -
|
||||
async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -> None:
|
||||
# The immich case: an on-canvas node from an old scan, with a legacy
|
||||
# inventory row (NULL source). Merge must keep the IP tag on the row.
|
||||
node = Node(
|
||||
id=str(uuid.uuid4()), type="lxc", label="immich",
|
||||
ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
status="online", pos_x=0, pos_y=0,
|
||||
)
|
||||
inv = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
suggested_type="lxc", status="approved",
|
||||
discovery_source=None, discovery_sources=[],
|
||||
)
|
||||
db_session.add_all([node, inv])
|
||||
db_session.add(inv)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
id=str(uuid.uuid4()), type="lxc", label="immich",
|
||||
device_id=inv.id, pos_x=0, pos_y=0,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(
|
||||
@@ -328,9 +336,15 @@ async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) ->
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
|
||||
# A scanned canvas node with a MAC but no IP recorded for the guest.
|
||||
scanned_device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), mac="bc:24:11:aa:bb:cc", suggested_type="generic",
|
||||
status="approved", discovery_source="arp", discovery_sources=["arp"],
|
||||
)
|
||||
db_session.add(scanned_device)
|
||||
await db_session.flush()
|
||||
scanned = Node(
|
||||
id=str(uuid.uuid4()), type="generic", label="box",
|
||||
mac="bc:24:11:aa:bb:cc", status="online", pos_x=0, pos_y=0,
|
||||
device_id=scanned_device.id, pos_x=0, pos_y=0,
|
||||
)
|
||||
db_session.add(scanned)
|
||||
await db_session.commit()
|
||||
@@ -339,7 +353,8 @@ async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
|
||||
|
||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
||||
assert len(nodes) == 1 # no duplicate node
|
||||
merged = nodes[0]
|
||||
merged = await db_session.get(InventoryDevice, nodes[0].device_id)
|
||||
assert merged is not None
|
||||
assert merged.ieee_address == "pve-pve1-101"
|
||||
assert merged.mac == "bc:24:11:aa:bb:cc"
|
||||
assert merged.cpu_count == 2 # specs backfilled
|
||||
@@ -420,12 +435,14 @@ async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
|
||||
# Two host nodes already on a canvas + a pending cluster link between them.
|
||||
design = Design(id=str(uuid.uuid4()), name="d")
|
||||
db_session.add(design)
|
||||
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", ieee_address="pve-node-a",
|
||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
||||
left_handles=1, right_handles=1)
|
||||
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", ieee_address="pve-node-b",
|
||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
||||
left_handles=1, right_handles=1)
|
||||
dev_a = InventoryDevice(id=str(uuid.uuid4()), ieee_address="pve-node-a", status="approved")
|
||||
dev_b = InventoryDevice(id=str(uuid.uuid4()), ieee_address="pve-node-b", status="approved")
|
||||
db_session.add_all([dev_a, dev_b])
|
||||
await db_session.flush()
|
||||
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", device_id=dev_a.id,
|
||||
pos_x=0, pos_y=0, design_id=design.id, left_handles=1, right_handles=1)
|
||||
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", device_id=dev_b.id,
|
||||
pos_x=0, pos_y=0, design_id=design.id, left_handles=1, right_handles=1)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
|
||||
@@ -451,13 +468,18 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
|
||||
da = Design(id=str(uuid.uuid4()), name="a")
|
||||
db_ = Design(id=str(uuid.uuid4()), name="b")
|
||||
db_session.add_all([da, db_])
|
||||
# One row per device, whatever the canvas count.
|
||||
dev_x = InventoryDevice(id=str(uuid.uuid4()), ieee_address="0xAAAA", status="approved")
|
||||
dev_y = InventoryDevice(id=str(uuid.uuid4()), ieee_address="0xBBBB", status="approved")
|
||||
db_session.add_all([dev_x, dev_y])
|
||||
await db_session.flush()
|
||||
# Same two devices placed on BOTH designs (one Node per canvas).
|
||||
for d in (da, db_):
|
||||
db_session.add_all([
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="x", ieee_address="0xAAAA",
|
||||
status="online", pos_x=0, pos_y=0, design_id=d.id),
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="y", ieee_address="0xBBBB",
|
||||
status="online", pos_x=0, pos_y=0, design_id=d.id),
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="x", device_id=dev_x.id,
|
||||
pos_x=0, pos_y=0, design_id=d.id),
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="y", device_id=dev_y.id,
|
||||
pos_x=0, pos_y=0, design_id=d.id),
|
||||
])
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
|
||||
|
||||
+32
-26
@@ -468,6 +468,21 @@ class TestInventory:
|
||||
"""The rack prints the logical view's facts, so the endpoint must ship them."""
|
||||
rack_design = await _design(client, headers)
|
||||
network = await _design(client, headers, name="Network", design_type="network")
|
||||
# Straight to the table: the manual-create schema carries no os/services,
|
||||
# and a scan find does. Seeded before the node so the node links to this
|
||||
# row rather than minting a second one for the same host.
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-nas",
|
||||
hostname="nas",
|
||||
ip="192.168.1.9",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="TrueNAS",
|
||||
suggested_type="nas",
|
||||
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
@@ -483,20 +498,6 @@ class TestInventory:
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
# Straight to the table: the manual-create schema carries no os/services,
|
||||
# and a scan find does.
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-nas",
|
||||
hostname="nas",
|
||||
ip="192.168.1.9",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="TrueNAS",
|
||||
suggested_type="nas",
|
||||
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
|
||||
@@ -507,8 +508,10 @@ class TestInventory:
|
||||
assert entry["services"] == [{"port": 445, "name": "smb"}]
|
||||
assert entry["node_label"] == "nas-truenas"
|
||||
assert entry["node_type"] == "nas"
|
||||
assert entry["node_hostname"] == "nas.lan"
|
||||
assert entry["node_os"] == "TrueNAS SCALE"
|
||||
# The node no longer keeps its own copy: both views read the row, which
|
||||
# the node create filled in without overwriting what the scan found.
|
||||
assert entry["node_hostname"] == "nas"
|
||||
assert entry["node_os"] == "TrueNAS"
|
||||
assert entry["node_check_method"] == "http"
|
||||
assert entry["node_design_id"] == network
|
||||
assert entry["node_design_name"] == "Network"
|
||||
@@ -525,17 +528,16 @@ class TestInventory:
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
db_session.add(InventoryDevice(id="pd-seen", ip="192.168.1.77", suggested_type="server"))
|
||||
await db_session.execute(
|
||||
text("UPDATE nodes SET last_seen = :ts WHERE id = :id"),
|
||||
{"ts": "2026-08-08 10:00:00.000000", "id": node["id"]},
|
||||
text("UPDATE device_inventory SET last_seen = :ts WHERE id = :id"),
|
||||
{"ts": "2026-08-08 10:00:00.000000", "id": node["device_id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == "pd-seen")
|
||||
entry = next(i for i in items if i["id"] == node["device_id"])
|
||||
assert entry["node_last_seen"].endswith(("Z", "+00:00"))
|
||||
|
||||
async def test_prefers_the_node_the_mount_names(self, client: AsyncClient, headers):
|
||||
@@ -630,9 +632,17 @@ class TestInventory:
|
||||
assert entry["node_design_name"] is None
|
||||
assert entry["services"] == []
|
||||
|
||||
async def test_falls_back_to_the_node_services(self, client: AsyncClient, headers, db_session):
|
||||
"""An approved device keeps its fingerprint on the node, not the inventory row."""
|
||||
async def test_reports_the_services_a_canvas_added(self, client: AsyncClient, headers, db_session):
|
||||
"""Services documented on a canvas reach the rack — they are the device's.
|
||||
|
||||
The scan found the host but no services; the user typed one on the
|
||||
logical canvas, and the rack prints it without the node being consulted.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(
|
||||
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
|
||||
)
|
||||
await db_session.commit()
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
@@ -643,10 +653,6 @@ class TestInventory:
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
db_session.add(
|
||||
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
|
||||
@@ -596,13 +596,20 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
|
||||
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
|
||||
"""A scan that sees a known device stamps last_scan on its inventory row.
|
||||
|
||||
The stamp is a fact about the device, so it lands once on the row every
|
||||
canvas draws — not once per node.
|
||||
"""
|
||||
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))
|
||||
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
|
||||
device = InventoryDevice(id="d1", ip="192.168.1.5", status="approved")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
session.add(Node(id="n1", type="server", label="NAS", device_id="d1"))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}]
|
||||
@@ -614,21 +621,24 @@ async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is not None
|
||||
assert device is not None
|
||||
assert device.last_scan is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
|
||||
"""A node with no IP but a matching MAC still gets last_scan stamped."""
|
||||
"""A device with no IP but a matching MAC still gets last_scan stamped."""
|
||||
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))
|
||||
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
|
||||
device = InventoryDevice(id="d2", mac="aa:bb:cc:dd:ee:ff", status="approved")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
session.add(Node(id="n2", type="iot", label="Sensor", device_id="d2"))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}]
|
||||
@@ -640,21 +650,24 @@ async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n2")
|
||||
device = await session.get(InventoryDevice, "d2")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is not None
|
||||
assert device is not None
|
||||
assert device.last_scan is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
||||
"""A node whose IP/MAC is not seen by the scan keeps last_scan = None."""
|
||||
"""A device whose IP/MAC the scan never saw keeps last_scan = None."""
|
||||
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))
|
||||
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
|
||||
device = InventoryDevice(id="d3", ip="10.0.0.99", status="approved")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
session.add(Node(id="n3", type="server", label="Other", device_id="d3"))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}]
|
||||
@@ -666,10 +679,10 @@ async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n3")
|
||||
device = await session.get(InventoryDevice, "d3")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is None
|
||||
assert device is not None
|
||||
assert device.last_scan is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -771,11 +784,12 @@ async def test_run_scan_keeps_canvas_nodes(mem_db):
|
||||
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)
|
||||
drawn = InventoryDevice(id="d-pve", ip="192.168.1.100", status="pending")
|
||||
session.add(drawn)
|
||||
await session.flush()
|
||||
session.add(Node(
|
||||
id=str(uuid.uuid4()), label="PVE", type="proxmox", device_id="d-pve",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
|
||||
|
||||
@@ -24,18 +24,25 @@ from app.core.scheduler import (
|
||||
stop_scheduler,
|
||||
)
|
||||
from app.db.database import Base
|
||||
from app.db.models import Node
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_node(**kwargs) -> Node:
|
||||
def _make_device(**kwargs) -> InventoryDevice:
|
||||
"""An inventory row — what the checker iterates now that a device is checked
|
||||
once rather than once per canvas."""
|
||||
defaults = dict(id=str(uuid.uuid4()), status="approved", status_live="unknown")
|
||||
return InventoryDevice(**{**defaults, **kwargs})
|
||||
|
||||
|
||||
def _make_node(device_id: str | None = None, **kwargs) -> Node:
|
||||
defaults = dict(
|
||||
id=str(uuid.uuid4()),
|
||||
type="server",
|
||||
label="Test",
|
||||
status="unknown",
|
||||
device_id=device_id,
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
@@ -58,11 +65,10 @@ async def mem_db():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_skips_nodes_without_check_method(mem_db):
|
||||
"""Nodes with no check_method are skipped; check_node is never called."""
|
||||
async def test_run_status_checks_skips_devices_without_check_method(mem_db):
|
||||
"""Devices with no check_method are skipped; check_node is never called."""
|
||||
async with mem_db() as session:
|
||||
node = _make_node(check_method=None, ip="10.0.0.1")
|
||||
session.add(node)
|
||||
session.add(_make_device(check_method=None, ip="10.0.0.1"))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
@@ -72,13 +78,16 @@ async def test_run_status_checks_skips_nodes_without_check_method(mem_db):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_updates_node_status(mem_db):
|
||||
"""check_node result is persisted to the DB and broadcast via WebSocket."""
|
||||
async def test_run_status_checks_updates_device_status(mem_db):
|
||||
"""check_node result lands on the device row and is broadcast to its nodes."""
|
||||
async with mem_db() as session:
|
||||
node = _make_node(check_method="ping", ip="10.0.0.1")
|
||||
device = _make_device(check_method="ping", ip="10.0.0.1")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
node = _make_node(device_id=device.id)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
node_id = node.id
|
||||
device_id, node_id = device.id, node.id
|
||||
|
||||
check_result = {"status": "online", "response_time_ms": 5}
|
||||
|
||||
@@ -87,28 +96,74 @@ async def test_run_status_checks_updates_node_status(mem_db):
|
||||
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock) as mock_broadcast:
|
||||
await _run_status_checks()
|
||||
|
||||
# Verify DB updated
|
||||
# Verify the row updated — `status_live` is reachability, `status` is the
|
||||
# pending/approved/hidden lifecycle and must not be touched.
|
||||
async with mem_db() as session:
|
||||
updated = await session.get(Node, node_id)
|
||||
updated = await session.get(InventoryDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.status == "online"
|
||||
assert updated.status_live == "online"
|
||||
assert updated.status == "approved"
|
||||
assert updated.response_time_ms == 5
|
||||
|
||||
# Verify WebSocket broadcast
|
||||
# Verify WebSocket broadcast, addressed by device and carrying its nodes
|
||||
mock_broadcast.assert_awaited_once()
|
||||
_, kwargs = mock_broadcast.call_args
|
||||
assert kwargs["node_id"] == node_id
|
||||
assert kwargs["device_id"] == device_id
|
||||
assert kwargs["node_ids"] == [node_id]
|
||||
assert kwargs["status"] == "online"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_covers_a_device_no_canvas_draws(mem_db):
|
||||
"""Deliberate: the inventory row, not a node, is what gets monitored.
|
||||
|
||||
A rack mount or an inventory-only entry reports live status without being
|
||||
drawn on any canvas, and deleting a node does not silently stop monitoring
|
||||
the host. `hide` (or clearing the check method) is what ends the checks.
|
||||
"""
|
||||
async with mem_db() as session:
|
||||
device = _make_device(check_method="ping", ip="10.0.0.1")
|
||||
session.add(device)
|
||||
await session.commit()
|
||||
device_id = device.id
|
||||
|
||||
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_node", new_callable=AsyncMock,
|
||||
return_value={"status": "online", "response_time_ms": 5}), \
|
||||
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock) as mock_broadcast:
|
||||
await _run_status_checks()
|
||||
|
||||
async with mem_db() as session:
|
||||
updated = await session.get(InventoryDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.status_live == "online"
|
||||
|
||||
_, kwargs = mock_broadcast.call_args
|
||||
assert kwargs["device_id"] == device_id
|
||||
assert kwargs["node_ids"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_skips_a_hidden_device(mem_db):
|
||||
"""Hiding a device is how a user stops the checks, node or no node."""
|
||||
async with mem_db() as session:
|
||||
session.add(_make_device(check_method="ping", ip="10.0.0.1", status="hidden"))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_node", new_callable=AsyncMock) as mock_check:
|
||||
await _run_status_checks()
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_sets_last_seen_only_when_online(mem_db):
|
||||
"""last_seen is updated only when status is 'online'."""
|
||||
async with mem_db() as session:
|
||||
node = _make_node(check_method="ping", ip="10.0.0.1", last_seen=None)
|
||||
session.add(node)
|
||||
device = _make_device(check_method="ping", ip="10.0.0.1", last_seen=None)
|
||||
session.add(device)
|
||||
await session.commit()
|
||||
node_id = node.id
|
||||
device_id = device.id
|
||||
|
||||
check_result = {"status": "offline", "response_time_ms": None}
|
||||
|
||||
@@ -118,18 +173,19 @@ async def test_run_status_checks_sets_last_seen_only_when_online(mem_db):
|
||||
await _run_status_checks()
|
||||
|
||||
async with mem_db() as session:
|
||||
updated = await session.get(Node, node_id)
|
||||
updated = await session.get(InventoryDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.last_seen is None # not set for offline
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_handles_check_error_gracefully(mem_db):
|
||||
"""An exception from check_node is logged and does not abort other nodes."""
|
||||
"""An exception from check_node is logged and does not abort other devices."""
|
||||
async with mem_db() as session:
|
||||
n1 = _make_node(check_method="ping", ip="10.0.0.1")
|
||||
n2 = _make_node(check_method="ping", ip="10.0.0.2")
|
||||
session.add_all([n1, n2])
|
||||
session.add_all([
|
||||
_make_device(check_method="ping", ip="10.0.0.1"),
|
||||
_make_device(check_method="ping", ip="10.0.0.2"),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
call_count = 0
|
||||
@@ -191,7 +247,7 @@ def test_start_and_stop_scheduler():
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||
session.add(_make_device(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
@@ -203,15 +259,18 @@ async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||
async def test_run_service_checks_broadcasts_per_device(mem_db):
|
||||
async with mem_db() as session:
|
||||
node = _make_node(
|
||||
device = _make_device(
|
||||
ip="10.0.0.5",
|
||||
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
)
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
node = _make_node(device_id=device.id)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
node_id = node.id
|
||||
device_id, node_id = device.id, node.id
|
||||
|
||||
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||
|
||||
@@ -224,14 +283,15 @@ async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||
|
||||
mock_bcast.assert_awaited_once()
|
||||
_, kwargs = mock_bcast.call_args
|
||||
assert kwargs["node_id"] == node_id
|
||||
assert kwargs["device_id"] == device_id
|
||||
assert kwargs["node_ids"] == [node_id]
|
||||
assert kwargs["services"] == statuses
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_skips_nodes_without_services(mem_db):
|
||||
async def test_run_service_checks_skips_devices_without_services(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(ip="10.0.0.6", services=[]))
|
||||
session.add(_make_device(ip="10.0.0.6", services=[]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
|
||||
@@ -37,6 +37,33 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
||||
assert res.json()["interval_seconds"] == 120
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_reschedules_status_checks(client: AsyncClient, headers):
|
||||
"""The new interval must reach the running scheduler, not just the config
|
||||
file: otherwise the UI and scan_config.json show the new value while the
|
||||
job keeps firing at the old one until the backend restarts."""
|
||||
with patch("app.api.routes.settings.settings") as mock_settings, \
|
||||
patch("app.api.routes.settings.reschedule_status_checks") as mock_reschedule:
|
||||
mock_settings.save_overrides = lambda: None
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={"interval_seconds": 120},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
mock_reschedule.assert_called_once_with(120)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_rejects_too_short_status_interval(client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={"interval_seconds": 5},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.services.fingerprint import _load, match_service
|
||||
|
||||
_NODE_TYPES = {
|
||||
"isp", "router", "switch", "server", "proxmox", "vm", "lxc",
|
||||
"nas", "iot", "ap", "camera", "generic",
|
||||
"nas", "kvm", "iot", "ap", "camera", "generic",
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,13 @@ def test_popular_apps_have_port_agnostic_signatures(signatures):
|
||||
assert expected in names, f"missing port-agnostic signature for {expected}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", ["PiKVM", "TinyPilot", "JetKVM", "NanoKVM"])
|
||||
def test_ip_kvm_titles_suggest_kvm_node_type(title):
|
||||
sig = match_service(80, "tcp", banner=None, http_signals={"title": title, "headers": {}})
|
||||
assert sig is not None
|
||||
assert sig["suggested_node_type"] == "kvm"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("title", "expected"), [
|
||||
("Jellyfin", "Jellyfin"),
|
||||
("Home Assistant", "Home Assistant"),
|
||||
|
||||
@@ -68,13 +68,21 @@ async def test_summary_aggregates_counts(
|
||||
) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
||||
# Reachability and the ieee live on the inventory row each node draws.
|
||||
drawn = [
|
||||
("A", "server", "online", None),
|
||||
("B", "server", "online", None),
|
||||
("C", "server", "offline", None),
|
||||
("D", "server", "unknown", None),
|
||||
("Z1", "iot", "online", "0x1"),
|
||||
("Z2", "iot", "online", "0x2"),
|
||||
]
|
||||
for label, node_type, status_live, ieee in drawn:
|
||||
device = InventoryDevice(status="approved", status_live=status_live, ieee_address=ieee)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(type=node_type, label=label, device_id=device.id))
|
||||
db_session.add_all([
|
||||
Node(type="server", label="A", status="online"),
|
||||
Node(type="server", label="B", status="online"),
|
||||
Node(type="server", label="C", status="offline"),
|
||||
Node(type="server", label="D", status="unknown"),
|
||||
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
||||
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
||||
InventoryDevice(ip="10.0.0.1", status="pending"),
|
||||
InventoryDevice(ip="10.0.0.2", status="pending"),
|
||||
InventoryDevice(ip="10.0.0.3", status="hidden"), # excluded
|
||||
|
||||
@@ -74,7 +74,8 @@ async def test_broadcast_status_sends_to_connected_clients():
|
||||
_connections.append(fake)
|
||||
try:
|
||||
await broadcast_status(
|
||||
node_id="node-1",
|
||||
device_id="dev-1",
|
||||
node_ids=["node-1", "node-2"],
|
||||
status="online",
|
||||
checked_at="2024-01-01T00:00:00",
|
||||
response_time_ms=42,
|
||||
@@ -85,7 +86,9 @@ async def test_broadcast_status_sends_to_connected_clients():
|
||||
assert len(received) == 1
|
||||
msg = json.loads(received[0])
|
||||
assert msg["type"] == "status"
|
||||
assert msg["node_id"] == "node-1"
|
||||
# One check, addressed by device, lighting up every canvas drawing it.
|
||||
assert msg["device_id"] == "dev-1"
|
||||
assert msg["node_ids"] == ["node-1", "node-2"]
|
||||
assert msg["status"] == "online"
|
||||
assert msg["response_time_ms"] == 42
|
||||
|
||||
@@ -102,7 +105,7 @@ async def test_broadcast_status_no_response_time():
|
||||
fake = FakeWS()
|
||||
_connections.append(fake)
|
||||
try:
|
||||
await broadcast_status(node_id="n", status="offline", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="offline", checked_at="t")
|
||||
finally:
|
||||
_connections.remove(fake)
|
||||
|
||||
@@ -122,7 +125,7 @@ async def test_broadcast_status_removes_dead_connection():
|
||||
_connections.append(dead)
|
||||
initial_len = len(_connections)
|
||||
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
|
||||
|
||||
assert dead not in _connections
|
||||
assert len(_connections) == initial_len - 1
|
||||
@@ -159,7 +162,7 @@ async def test_broadcast_scan_update():
|
||||
async def test_broadcast_no_connections():
|
||||
"""broadcast_* with no connections must not raise."""
|
||||
assert len(_connections) == 0
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
|
||||
await broadcast_scan_update(run_id="r", devices_found=0)
|
||||
|
||||
|
||||
@@ -179,7 +182,8 @@ async def test_broadcast_service_status_payload():
|
||||
_connections.append(fake)
|
||||
try:
|
||||
await broadcast_service_status(
|
||||
node_id="node-7",
|
||||
device_id="dev-7",
|
||||
node_ids=["node-7"],
|
||||
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
|
||||
checked_at="2024-01-01T00:00:00",
|
||||
)
|
||||
@@ -188,7 +192,8 @@ async def test_broadcast_service_status_payload():
|
||||
|
||||
msg = json.loads(received[0])
|
||||
assert msg["type"] == "service_status"
|
||||
assert msg["node_id"] == "node-7"
|
||||
assert msg["device_id"] == "dev-7"
|
||||
assert msg["node_ids"] == ["node-7"]
|
||||
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||
|
||||
|
||||
@@ -217,7 +222,7 @@ async def test_broadcast_dead_connection_dropped_once_safely():
|
||||
|
||||
dead = DeadWS()
|
||||
_connections.append(dead)
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
|
||||
# A second broadcast must not raise even though dead is already gone.
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
|
||||
assert dead not in _connections
|
||||
|
||||
@@ -467,11 +467,86 @@ async def test_check_services_returns_status_per_service():
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||
results = await check_services("10.0.0.1", services)
|
||||
assert results == [
|
||||
{"port": 80, "protocol": "tcp", "status": "online"},
|
||||
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
||||
{"port": 80, "protocol": "tcp", "host": None, "status": "online"},
|
||||
{"port": 5432, "protocol": "tcp", "host": None, "status": "unknown"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_echoes_the_host_override():
|
||||
services = [{"port": 443, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com"}]
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||
results = await check_services("10.0.0.1", services)
|
||||
assert results == [
|
||||
{"port": 443, "protocol": "tcp", "host": "blog.example.com", "status": "online"},
|
||||
]
|
||||
|
||||
|
||||
# --- Per-service host override ---
|
||||
|
||||
|
||||
async def _captured_url(svc, host):
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_service(svc, host)
|
||||
return captured.get("url")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_uses_the_host_override():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_falls_back_to_the_node_host_when_override_is_blank():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": " "}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://10.0.0.1:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_honours_a_scheme_in_the_override():
|
||||
svc = {"protocol": "tcp", "service_name": "blog", "host": "https://blog.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "https://blog.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_takes_the_port_from_the_override():
|
||||
svc = {"protocol": "tcp", "service_name": "blog", "host": "blog.example.com:8443"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "https://blog.example.com:8443"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_lets_the_service_port_beat_the_override_port():
|
||||
svc = {"port": 3000, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com:8443"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:3000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_uses_the_first_host_of_a_comma_list_override():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com, alt.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_brackets_an_ipv6_override():
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http", "host": "[2001:db8::1]:8080"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://[2001:db8::1]:80"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_skips_a_non_http_port_behind_an_override():
|
||||
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres", "host": "db.example.com"}
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_get:
|
||||
assert await check_service(svc, "10.0.0.1") == "unknown"
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_empty_list():
|
||||
assert await check_services("10.0.0.1", []) == []
|
||||
|
||||
@@ -436,15 +436,18 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
|
||||
|
||||
# Simulate: router was approved earlier → exists as a canvas Node, but with
|
||||
# no matching device_inventory row (e.g. a legacy auto-placed device).
|
||||
approved = Node(
|
||||
label="router_1",
|
||||
type="zigbee_router",
|
||||
status="online",
|
||||
check_method="none",
|
||||
device = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
services=[],
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
status="approved",
|
||||
status_live="online",
|
||||
check_method="none",
|
||||
properties=[],
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
approved = Node(label="router_1", type="zigbee_router", device_id=device.id)
|
||||
db_session.add(approved)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -464,7 +467,9 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
|
||||
|
||||
# Node properties got refreshed.
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
|
||||
@@ -483,14 +488,15 @@ async def test_persist_pending_import_preserves_hidden_inventory_for_approved_no
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
db_session.add(Node(
|
||||
label="router_1", type="zigbee_router", status="online",
|
||||
check_method="none", ieee_address="0xR1", services=[], properties=[],
|
||||
))
|
||||
db_session.add(InventoryDevice(
|
||||
# One row, hidden by the user, and a canvas still draws it.
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
|
||||
device_subtype="Router", status="hidden", discovery_source="zigbee",
|
||||
))
|
||||
device_subtype="Router", status="hidden", status_live="online",
|
||||
check_method="none", discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(drawn)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(label="router_1", type="zigbee_router", device_id=drawn.id))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
@@ -595,21 +601,24 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
approved = Node(
|
||||
label="router_1",
|
||||
type="zigbee_router",
|
||||
status="online",
|
||||
check_method="none",
|
||||
device = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
services=[],
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
status="approved",
|
||||
status_live="online",
|
||||
check_method="none",
|
||||
properties=[
|
||||
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
|
||||
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
|
||||
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
|
||||
],
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
approved = Node(label="router_1", type="zigbee_router", device_id=device.id)
|
||||
db_session.add(approved)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -619,7 +628,9 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
by_key = {p["key"]: p for p in refreshed.properties}
|
||||
# Existing keys keep their visibility (True).
|
||||
@@ -647,10 +658,14 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
# Legacy: coordinator auto-placed as a canvas Node, no device_inventory row.
|
||||
db_session.add(Node(
|
||||
label="Coordinator", type="zigbee_coordinator", status="online",
|
||||
check_method="none", ieee_address="0xCOORD", services=[], properties=[],
|
||||
))
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="0xCOORD", friendly_name="Coordinator",
|
||||
suggested_type="zigbee_coordinator", status="approved", status_live="online",
|
||||
check_method="none",
|
||||
)
|
||||
db_session.add(drawn)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(label="Coordinator", type="zigbee_coordinator", device_id=drawn.id))
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
@@ -659,7 +674,9 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
coord = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
|
||||
)
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||
assert keys["Vendor"] == "TI"
|
||||
@@ -685,17 +702,19 @@ async def test_persist_pending_import_device_on_multiple_canvases(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Design, Node
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
|
||||
d1 = Design(name="d1")
|
||||
d2 = Design(name="d2")
|
||||
db_session.add_all([d1, d2])
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
|
||||
status="approved", status_live="online", check_method="none",
|
||||
)
|
||||
db_session.add_all([d1, d2, drawn])
|
||||
await db_session.flush()
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(
|
||||
label="router_1", type="zigbee_router", status="online",
|
||||
check_method="none", ieee_address="0xR1", services=[],
|
||||
properties=[], design_id=d.id,
|
||||
label="router_1", type="zigbee_router", device_id=drawn.id, design_id=d.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
@@ -705,10 +724,12 @@ async def test_persist_pending_import_device_on_multiple_canvases(
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
await db_session.execute(select(Node).where(Node.device_id == drawn.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2 # both canvas placements preserved
|
||||
for n in nodes:
|
||||
# One row behind them, so one refresh serves both canvases.
|
||||
await db_session.refresh(drawn)
|
||||
for n in [drawn]:
|
||||
lqi = {p["key"]: p["value"] for p in n.properties}.get("LQI")
|
||||
assert lqi == "240" # refreshed on every canvas
|
||||
|
||||
|
||||
@@ -374,16 +374,14 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
approved = Node(
|
||||
label="Wall Plug",
|
||||
type="zwave_router",
|
||||
status="online",
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="zwave-0xh-2", friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router", status="approved", status_live="online",
|
||||
check_method="none",
|
||||
ieee_address="zwave-0xh-2",
|
||||
services=[],
|
||||
properties=[],
|
||||
)
|
||||
db_session.add(approved)
|
||||
db_session.add(drawn)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(label="Wall Plug", type="zwave_router", device_id=drawn.id))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
@@ -396,7 +394,9 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
|
||||
assert inv.status == "approved"
|
||||
assert inv.suggested_type == "zwave_router"
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
|
||||
@@ -466,17 +466,20 @@ async def test_persist_device_on_multiple_canvases(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import Design, Node
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
|
||||
d1 = Design(name="d1")
|
||||
d2 = Design(name="d2")
|
||||
db_session.add_all([d1, d2])
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="zwave-0xh-2", friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router", status="approved", status_live="online",
|
||||
check_method="none",
|
||||
)
|
||||
db_session.add_all([d1, d2, drawn])
|
||||
await db_session.flush()
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(
|
||||
label="Wall Plug", type="zwave_router", status="online",
|
||||
check_method="none", ieee_address="zwave-0xh-2", services=[],
|
||||
properties=[], design_id=d.id,
|
||||
label="Wall Plug", type="zwave_router", device_id=drawn.id, design_id=d.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
@@ -486,12 +489,13 @@ async def test_persist_device_on_multiple_canvases(db_session) -> None:
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||
await db_session.execute(select(Node).where(Node.device_id == drawn.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2 # both canvas placements preserved
|
||||
for n in nodes:
|
||||
model = {p["key"]: p["value"] for p in n.properties}.get("Model")
|
||||
assert model == "ZW200" # refreshed on every canvas
|
||||
# One row behind them, so one refresh serves every canvas.
|
||||
await db_session.refresh(drawn)
|
||||
model = {p["key"]: p["value"] for p in drawn.properties}.get("Model")
|
||||
assert model == "ZW200"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Generated
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.1",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -6578,9 +6578,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9514,9 +9514,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+32
-5
@@ -64,7 +64,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, addToZone, floorMap, setFloorMap } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated, isInitialized } = useAuthStore()
|
||||
const authBootstrapStarted = useRef(false)
|
||||
@@ -133,6 +133,7 @@ export default function App() {
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||
const [pendingZoneAdd, setPendingZoneAdd] = useState<{ nodeId: string; zoneId: string } | null>(null)
|
||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
@@ -167,7 +168,10 @@ export default function App() {
|
||||
if (!options?.silent) toast.success('Canvas saved')
|
||||
return true
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
// Read the baseline at save time (not through the render-time destructure)
|
||||
// so a device edited in the inventory a moment ago is already rebased.
|
||||
const factsBaseline = useCanvasStore.getState().factsBaseline
|
||||
const nodesToSave = nodes.map((n) => serializeNode(n, factsBaseline[n.id]))
|
||||
const edgesToSave = edges.map(serializeEdge)
|
||||
const viewport: Record<string, unknown> = { theme_id: activeTheme }
|
||||
if (floorMap) viewport.floor_map = floorMap
|
||||
@@ -221,8 +225,11 @@ export default function App() {
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const zoneIds = new Set(
|
||||
(apiNodes as ApiNode[]).filter((n) => n.type === 'groupRect').map((n) => n.id)
|
||||
)
|
||||
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap, zoneIds)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
@@ -498,7 +505,9 @@ export default function App() {
|
||||
// parent the LXC/VM stays a free node (linked by a virtual edge) — setting
|
||||
// extent:'parent' on a non-container would trap it inside the parent's tiny
|
||||
// bounding box with no way to drag it out (issue #205 follow-up).
|
||||
const nestInParent = !!parentNode?.data.container_mode
|
||||
// A visual group nests too, so it seeds its position the same way — mirrors
|
||||
// the condition in the store's addNode, which is the authority here.
|
||||
const nestInParent = !!parentNode?.data.container_mode || parentNode?.data.type === 'group'
|
||||
// Seed an ABSOLUTE position near the container's top-left; addNode converts
|
||||
// it to container-relative. addNode is the single authority for parentId /
|
||||
// extent, so we don't set them here.
|
||||
@@ -1048,6 +1057,7 @@ export default function App() {
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onRequestAddToGroup={setPendingGroupAdd}
|
||||
onRequestAddToContainer={setPendingContainerAdd}
|
||||
onRequestAddToZone={setPendingZoneAdd}
|
||||
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
|
||||
/>
|
||||
)}
|
||||
@@ -1058,7 +1068,12 @@ export default function App() {
|
||||
{isRackDesign
|
||||
? <RackCablePanel />
|
||||
: (selectedNodeId || selectedNodeIds.length > 1) && (
|
||||
<DetailPanel onEdit={handleEditNode} />
|
||||
<DetailPanel
|
||||
onEdit={handleEditNode}
|
||||
// Standalone has no Device Inventory to open (ADR-001 style
|
||||
// gate: the whole scan surface is backend-only).
|
||||
onOpenInventory={STANDALONE ? undefined : (deviceId) => openInventoryModal(deviceId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1285,6 +1300,18 @@ export default function App() {
|
||||
onCancel={() => setPendingContainerAdd(null)}
|
||||
/>
|
||||
|
||||
<ConfirmAddToGroupModal
|
||||
open={!!pendingZoneAdd}
|
||||
variant="zone"
|
||||
nodeLabel={pendingZoneAdd ? (nodes.find((n) => n.id === pendingZoneAdd.nodeId)?.data.label ?? '') : ''}
|
||||
targetLabel={pendingZoneAdd ? (nodes.find((n) => n.id === pendingZoneAdd.zoneId)?.data.label ?? '') : ''}
|
||||
onConfirm={() => {
|
||||
if (pendingZoneAdd) addToZone(pendingZoneAdd.zoneId, pendingZoneAdd.nodeId)
|
||||
setPendingZoneAdd(null)
|
||||
}}
|
||||
onCancel={() => setPendingZoneAdd(null)}
|
||||
/>
|
||||
|
||||
{/* Mounted in standalone too: status-check settings are hidden inside,
|
||||
but canvas prefs (snap, hide-IP) still apply. */}
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthMode, AuthUser } from '@/stores/authStore'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
@@ -150,6 +151,13 @@ export const scanApi = {
|
||||
/** "manual" (default) or "rack" for gear created from a rack canvas. */
|
||||
discovery_source?: 'manual' | 'rack'
|
||||
}) => api.post<{ id: string; hostname: string | null }>('/scan/pending', data),
|
||||
/**
|
||||
* Edit an inventory row. Partial: only the keys sent are applied, so a caller
|
||||
* touching one field never clears the rest. Lifecycle (`status`) and discovery
|
||||
* bookkeeping are not editable — approve/hide own those.
|
||||
*/
|
||||
updatePending: (id: string, data: Partial<Omit<InventoryEntry, 'id' | 'status' | 'discovered_at'>>) =>
|
||||
api.patch<InventoryEntry>(`/scan/pending/${id}`, data),
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
clearPending: () => api.delete('/scan/pending'),
|
||||
|
||||
@@ -34,10 +34,11 @@ interface CanvasContainerProps {
|
||||
onNodeDragStart?: () => void
|
||||
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||
onRequestAddToZone?: (payload: { nodeId: string; zoneId: string }) => void
|
||||
onOpenInventory?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenInventory }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onRequestAddToZone, onOpenInventory }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
@@ -45,6 +46,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
setSelectedNode, snapshotHistory,
|
||||
fitViewPending, clearFitViewPending,
|
||||
copySelectedNodes, pasteNodes,
|
||||
removeFromGroup,
|
||||
} = useCanvasStore()
|
||||
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||
|
||||
@@ -146,20 +148,35 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||
// before the alignment snap so detection uses the dropped position.
|
||||
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||
if (dragNode && !dragNode.parentId &&
|
||||
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||
if (dragNode && dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||
const intersecting = getIntersectingNodes(dragNode)
|
||||
const group = intersecting.find((n) => n.data.type === 'group')
|
||||
if (group) {
|
||||
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||
} else {
|
||||
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||
const zoneParent = dragNode.parentId
|
||||
? nodes.find((n) => n.id === dragNode.parentId && n.data.type === 'groupRect')
|
||||
: undefined
|
||||
if (zoneParent) {
|
||||
// Zone children are not extent-clamped, so a drop outside the zone is
|
||||
// how the user takes a node back out of it.
|
||||
if (!intersecting.some((n) => n.id === zoneParent.id)) {
|
||||
removeFromGroup(zoneParent.id, dragNode.id)
|
||||
}
|
||||
} else if (!dragNode.parentId) {
|
||||
const group = intersecting.find((n) => n.data.type === 'group')
|
||||
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||
if (group) {
|
||||
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||
} else if (container) {
|
||||
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||
onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||
} else {
|
||||
// Zones come last: they are the loosest container and the largest, so
|
||||
// a group/container inside one still wins the drop.
|
||||
const zone = intersecting.find((n) => n.data.type === 'groupRect')
|
||||
if (zone) onRequestAddToZone?.({ nodeId: dragNode.id, zoneId: zone.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
onNodeDragStop(event, dragNode, dragNodes)
|
||||
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||
}, [onRequestAddToGroup, onRequestAddToContainer, onRequestAddToZone, removeFromGroup, nodes, getIntersectingNodes, onNodeDragStop])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||
|
||||
@@ -251,6 +251,63 @@ describe('CanvasContainer', () => {
|
||||
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Drag onto a zone → onRequestAddToZone / detach ────────────────────────
|
||||
|
||||
function zoneNode(id: string): Node<NodeData> {
|
||||
return { id, type: 'groupRect', position: { x: 0, y: 0 }, data: { label: id, type: 'groupRect', status: 'unknown', services: [] } }
|
||||
}
|
||||
|
||||
it('fires onRequestAddToZone when a node is dropped over a zone', () => {
|
||||
const onRequestAddToZone = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
rf.intersecting = [zoneNode('z1')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToZone).toHaveBeenCalledWith({ nodeId: 'n1', zoneId: 'z1' })
|
||||
})
|
||||
|
||||
it('prefers a group and a container over a zone when they overlap', () => {
|
||||
const onRequestAddToZone = vi.fn()
|
||||
const onRequestAddToContainer = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
rf.intersecting = [zoneNode('z1'), containerNode('px1')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||
expect(onRequestAddToZone).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire onRequestAddToZone when the dragged node is a zone itself', () => {
|
||||
const onRequestAddToZone = vi.fn()
|
||||
const node = zoneNode('z2')
|
||||
rf.intersecting = [zoneNode('z1')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToZone).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detaches a zone child dropped outside its zone', () => {
|
||||
const zone = { ...zoneNode('z1'), position: { x: 100, y: 100 } }
|
||||
const child = { ...makeNode('n1'), parentId: 'z1', position: { x: 20, y: 20 } }
|
||||
useCanvasStore.setState({ nodes: [zone, child] })
|
||||
rf.intersecting = []
|
||||
render(<CanvasContainer />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, child, [child])
|
||||
const after = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||
expect(after.parentId).toBeUndefined()
|
||||
expect(after.position).toEqual({ x: 120, y: 120 })
|
||||
})
|
||||
|
||||
it('keeps a zone child parented when it is dropped inside its zone', () => {
|
||||
const zone = zoneNode('z1')
|
||||
const child = { ...makeNode('n1'), parentId: 'z1' }
|
||||
useCanvasStore.setState({ nodes: [zone, child] })
|
||||
rf.intersecting = [zone]
|
||||
render(<CanvasContainer />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, child, [child])
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('z1')
|
||||
})
|
||||
|
||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||
|
||||
it('enables snapToGrid', () => {
|
||||
|
||||
@@ -148,7 +148,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
{prop.value.trim() && (
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -162,7 +164,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||
{services.map((svc, idx) => {
|
||||
const url = getServiceUrl(svc, serviceHost)
|
||||
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
|
||||
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol, svc.host)] === 'offline'
|
||||
const row = (
|
||||
<div
|
||||
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { getZoneSpatialChildren } from '@/utils/collapseFilter'
|
||||
import { getZoneChildren } from '@/utils/collapseFilter'
|
||||
import type { NodeData, TextPosition } from '@/types'
|
||||
|
||||
const FONT_FAMILIES: Record<string, string> = {
|
||||
@@ -55,11 +55,11 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||
const posStyle = POSITION_STYLES[textPos]
|
||||
|
||||
// Count children for collapse badge — groupRect zones don't parent their
|
||||
// contents via React Flow parentId, so we hit-test by spatial containment.
|
||||
// Count children for collapse badge — a zone holds both real parentId
|
||||
// children (dropped inside) and top-level nodes merely overlapping it.
|
||||
const selfNode = (nodes ?? []).find((n) => n.id === id)
|
||||
const childrenCount = selfNode
|
||||
? getZoneSpatialChildren(selfNode, nodes ?? []).length
|
||||
? getZoneChildren(selfNode, nodes ?? []).length
|
||||
: 0
|
||||
|
||||
const outsideJustify = textPos.includes('right') ? 'flex-end'
|
||||
|
||||
@@ -120,7 +120,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
{prop.value.trim() && (
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { nodeTypes } from '../nodeTypes'
|
||||
import { NODE_TYPE_LABELS, type NodeType } from '@/types'
|
||||
import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
|
||||
describe('nodeTypes registry', () => {
|
||||
it('registers a component for every declared node type', () => {
|
||||
// Adding a NodeType touches several hand-written maps; the Record<NodeType, …>
|
||||
// ones fail typecheck when one is missed, this registry does not.
|
||||
for (const t of Object.keys(NODE_TYPE_LABELS) as NodeType[]) {
|
||||
expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives the KVM type a label, an icon and a component', () => {
|
||||
expect(NODE_TYPE_LABELS.kvm).toBe('KVM Switch')
|
||||
expect(NODE_TYPE_DEFAULT_ICONS.kvm).toBeDefined()
|
||||
expect(nodeTypes.kvm).toBeDefined()
|
||||
})
|
||||
|
||||
it('registers a component for every wireless mesh node type', () => {
|
||||
// Regression: zwave_* types were missing, so React Flow fell back to the
|
||||
// default (unstyled) node — no icon, no accent. (Zigbee covered too.)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, MonitorCog, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2,
|
||||
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
||||
} from 'lucide-react'
|
||||
import { BaseNode } from './BaseNode'
|
||||
@@ -18,6 +18,7 @@ export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
||||
export const VmNode = (props: N) => <BaseNode {...props} icon={Box} />
|
||||
export const LxcNode = (props: N) => <BaseNode {...props} icon={Container} />
|
||||
export const NasNode = (props: N) => <BaseNode {...props} icon={HardDrive} />
|
||||
export const KvmNode = (props: N) => <BaseNode {...props} icon={MonitorCog} />
|
||||
export const IotNode = (props: N) => <BaseNode {...props} icon={Cpu} />
|
||||
export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} />
|
||||
export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode,
|
||||
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
||||
NasNode, KvmNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
||||
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
||||
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
||||
ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode,
|
||||
@@ -23,6 +23,7 @@ export const nodeTypes = {
|
||||
vm: VmNode,
|
||||
lxc: LxcNode,
|
||||
nas: NasNode,
|
||||
kvm: KvmNode,
|
||||
iot: IotNode,
|
||||
ap: ApNode,
|
||||
camera: CameraNode,
|
||||
|
||||
@@ -38,7 +38,7 @@ export function PropertyList({
|
||||
onChange,
|
||||
visibleLabel = 'Show on node',
|
||||
keyPlaceholder = 'Label (e.g. CPU Model)',
|
||||
valuePlaceholder = 'Value (e.g. i7-12700K)',
|
||||
valuePlaceholder = 'Value — optional (e.g. i7-12700K)',
|
||||
suggestions,
|
||||
title = 'Properties',
|
||||
emptyHint = 'No properties — click Add to define one.',
|
||||
@@ -51,7 +51,8 @@ export function PropertyList({
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newProp.key.trim() || !newProp.value.trim()) return
|
||||
// Only the key is required — a property may carry a label alone.
|
||||
if (!newProp.key.trim()) return
|
||||
onChange([
|
||||
...properties,
|
||||
{ key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible },
|
||||
@@ -82,7 +83,7 @@ export function PropertyList({
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
|
||||
if (editingIndex === null || !editProp.key.trim()) return
|
||||
onChange(
|
||||
properties.map((p, i) =>
|
||||
i === editingIndex
|
||||
@@ -192,7 +193,7 @@ export function PropertyList({
|
||||
)
|
||||
}
|
||||
|
||||
export function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel, visibleLabel = 'Show on node', keyPlaceholder = 'Label (e.g. CPU Model)', valuePlaceholder = 'Value (e.g. i7-12700K)' }: {
|
||||
export function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel, visibleLabel = 'Show on node', keyPlaceholder = 'Label (e.g. CPU Model)', valuePlaceholder = 'Value — optional (e.g. i7-12700K)' }: {
|
||||
form: PropForm
|
||||
onChange: (f: PropForm) => void
|
||||
onConfirm: () => void
|
||||
@@ -307,7 +308,9 @@ export function PropertyBadge({ prop, visibleLabel = 'node', draggable, isDraggi
|
||||
)}
|
||||
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
||||
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||
{prop.value.trim() && (
|
||||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('PropertyList', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a property with no key or no value', () => {
|
||||
it('adds a property with a label and no value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={[]} onChange={onChange} />)
|
||||
|
||||
@@ -48,9 +48,27 @@ describe('PropertyList', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Label/), { target: { value: 'GPU' } })
|
||||
fireEvent.click(confirm('Add'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{ key: 'GPU', value: '', icon: null, visible: true }])
|
||||
})
|
||||
|
||||
it('refuses a property with no key', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={[]} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'RTX' } })
|
||||
fireEvent.click(confirm('Add'))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prints a value-less property as a bare label', () => {
|
||||
render(<PropertyList properties={[{ key: 'Rented', value: '', icon: null, visible: true }]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTitle('Rented')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^·/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles a property between shown and hidden', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={props()} onChange={onChange} />)
|
||||
@@ -110,6 +128,17 @@ describe('PropertyList', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('saves an edit that clears the value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={props()} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Edit property')[1])
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } })
|
||||
fireEvent.click(confirm('Save'))
|
||||
|
||||
expect(onChange.mock.calls[0][0][1]).toEqual({ key: 'B', value: '', icon: null, visible: true })
|
||||
})
|
||||
|
||||
it('words the visibility toggle for the surface it edits', () => {
|
||||
render(<PropertyList properties={props()} onChange={vi.fn()} visibleLabel="Show on canvas" />)
|
||||
expect(screen.getAllByTitle('Hide on canvas')).toHaveLength(3)
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ConfirmAddToGroupModalProps {
|
||||
/** Label of the destination group/container. */
|
||||
targetLabel: string
|
||||
/** Destination kind — drives the wording. Defaults to 'group'. */
|
||||
variant?: 'group' | 'container'
|
||||
variant?: 'group' | 'container' | 'zone'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
@@ -28,8 +28,8 @@ export function ConfirmAddToGroupModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmAddToGroupModalProps) {
|
||||
const action = variant === 'container' ? 'Add to container' : 'Add to group'
|
||||
const noun = variant === 'container' ? 'container' : 'group'
|
||||
const noun = variant === 'container' ? 'container' : variant === 'zone' ? 'zone' : 'group'
|
||||
const action = `Add to ${noun}`
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { clampHandles, sideDefault } from '@/utils/handleUtils'
|
||||
import { MIN_HANDLES, clampHandles, sideDefault } from '@/utils/handleUtils'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
import type {
|
||||
@@ -26,7 +26,7 @@ import { MarkerShapePicker } from './MarkerShapePicker'
|
||||
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'kvm', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||
@@ -229,7 +229,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
|
||||
<span className="text-xs text-[#8b949e] w-12">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={sideDefault(side)}
|
||||
min={MIN_HANDLES}
|
||||
max={64}
|
||||
step={1}
|
||||
value={style[key] ?? sideDefault(side)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import {
|
||||
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||
Server, Layers,
|
||||
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
|
||||
} from 'lucide-react'
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
import { toast } from 'sonner'
|
||||
import { InventoryDeviceModal, type InventoryEntry } from '@/components/modals/InventoryDeviceModal'
|
||||
import type { NodeType, ServiceInfo } from '@/types'
|
||||
@@ -72,25 +73,20 @@ function serviceColor(port: number | null | undefined, category?: string | null)
|
||||
return '#8b949e'
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
switch: Network,
|
||||
generic: Circle,
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | SourceBucket
|
||||
type StatusFilter = 'pending' | 'hidden'
|
||||
|
||||
const COMMON_PORTS = new Set([22, 80, 443])
|
||||
|
||||
/**
|
||||
* What the device is. The curated `type` wins over the discovery guess: a
|
||||
* device drawn on a canvas only ever has the former, and an edit that retypes a
|
||||
* scanned row must not keep reading the scanner's opinion.
|
||||
*/
|
||||
function deviceType(d: InventoryEntry): NodeType | null {
|
||||
return ((d.type ?? d.suggested_type) as NodeType) ?? null
|
||||
}
|
||||
|
||||
function specialServiceName(d: InventoryEntry): string | undefined {
|
||||
const candidates = (d.services ?? []).filter(
|
||||
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
|
||||
@@ -101,7 +97,8 @@ function specialServiceName(d: InventoryEntry): string | undefined {
|
||||
}
|
||||
|
||||
function deviceLabel(d: InventoryEntry): string {
|
||||
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
// Same precedence as the detail modal — the name the user gave it first.
|
||||
return d.label ?? d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
}
|
||||
|
||||
// Pull the duplicate-conflict payload out of a 409 approve response, if that's
|
||||
@@ -182,7 +179,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
|
||||
const distinctTypes = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
|
||||
devices.forEach((d) => { const t = deviceType(d); if (t) set.add(t) })
|
||||
return [...set].sort()
|
||||
}, [devices])
|
||||
|
||||
@@ -190,7 +187,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
const q = search.trim().toLowerCase()
|
||||
return devices.filter((d) => {
|
||||
if (sourceFilter !== 'all' && !sourceBuckets(d).has(sourceFilter)) return false
|
||||
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||
if (typeFilter !== 'all' && deviceType(d) !== typeFilter) return false
|
||||
// Inventory-only: optionally hide devices already placed on a canvas.
|
||||
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
|
||||
if (withServicesOnly && (d.services?.length ?? 0) === 0) return false
|
||||
@@ -302,7 +299,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
return
|
||||
}
|
||||
const fallbackLabel = deviceLabel(device)
|
||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||
const type = deviceType(device) ?? 'generic'
|
||||
const zwave = isZwaveType(type)
|
||||
const wireless = isZigbeeType(type) || zwave
|
||||
const properties = zwave
|
||||
@@ -405,7 +402,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
approvedDevices.forEach((d, i) => {
|
||||
const nodeId = deviceToNode[d.id]
|
||||
if (!nodeId) return
|
||||
const type = (d.suggested_type ?? 'generic') as NodeType
|
||||
const type = deviceType(d) ?? 'generic'
|
||||
const zwave = isZwaveType(type)
|
||||
const wireless = isZigbeeType(type) || zwave
|
||||
addNode({
|
||||
@@ -594,6 +591,13 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
>
|
||||
Rack devices
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('canvas')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'canvas' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
title="Documented directly on a canvas — no scan ever saw it"
|
||||
>
|
||||
Canvas
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
@@ -798,6 +802,12 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
onApprove={handleApprove}
|
||||
onHide={handleHide}
|
||||
onIgnore={handleIgnore}
|
||||
onSaved={(saved) => {
|
||||
// Patch in place rather than refetch: the grid keeps its scroll
|
||||
// position and the open detail modal keeps showing the same device.
|
||||
setDevices((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
|
||||
setSelected(saved)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -814,8 +824,8 @@ interface DeviceCardProps {
|
||||
|
||||
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||
const sources = orderedSources(device)
|
||||
const roleType = (device.suggested_type ?? 'generic') as NodeType
|
||||
const Icon = TYPE_ICONS[roleType] ?? Circle
|
||||
const roleType = deviceType(device) ?? 'generic'
|
||||
const Icon = NODE_TYPE_DEFAULT_ICONS[roleType] ?? NODE_TYPE_DEFAULT_ICONS.generic
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
// Colour the role badge with the same accent the node uses on the canvas
|
||||
// (from the active theme / style section), instead of a flat grey.
|
||||
@@ -893,12 +903,12 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
{SOURCE_META[s].label}
|
||||
</span>
|
||||
))}
|
||||
{device.suggested_type && (
|
||||
{roleType !== 'generic' && (
|
||||
<span
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={{ background: `${roleColor}22`, color: roleColor }}
|
||||
>
|
||||
{device.suggested_type}
|
||||
{roleType}
|
||||
</span>
|
||||
)}
|
||||
{device.lqi != null && (
|
||||
|
||||
@@ -1,49 +1,60 @@
|
||||
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
|
||||
/**
|
||||
* Device Inventory detail — show *and* edit.
|
||||
*
|
||||
* The inventory row owns what a device is; a canvas node only owns how it is
|
||||
* drawn. So everything a user can curate on a node — properties, services,
|
||||
* notes, check method — is editable here too, through the same editors the
|
||||
* canvas uses (`PropertyList`, `ServiceModal`).
|
||||
*
|
||||
* Hardware specs are properties like any other: `cpu_*` / `ram_gb` / `disk_gb`
|
||||
* still carry what Proxmox and the YAML import record, but nothing draws them,
|
||||
* so they are not edited here — CPU / RAM / Disk are property suggestions.
|
||||
*
|
||||
* Layout mirrors `NodeModal`: one wide dialog, a header carrying identity and
|
||||
* badges, then three columns — identity / operations / curation — so a device
|
||||
* reads in one screen instead of a scrolling column of key-value pairs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Factory,
|
||||
Fingerprint,
|
||||
HeartPulse,
|
||||
History,
|
||||
Layers,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
StickyNote,
|
||||
Tags,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isRackDevice } from '@/utils/deviceSources'
|
||||
import type { NodeProperty } from '@/types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { PropertyList } from '@/components/common/PropertyList'
|
||||
import { ServiceModal } from './ServiceModal'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { deviceFactsToNodeData } from '@/utils/deviceFacts'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
import { isRackDevice, orderedSources, SOURCE_META } from '@/utils/deviceSources'
|
||||
import { DEVICE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
|
||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
import { NODE_TYPE_LABELS, type CheckMethod, type InventoryEntry, type NodeProperty, type NodeType, type ServiceInfo } from '@/types'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
|
||||
interface Service {
|
||||
port: number
|
||||
protocol: string
|
||||
service_name: string
|
||||
icon?: string | null
|
||||
category?: string | null
|
||||
}
|
||||
|
||||
export interface InventoryEntry {
|
||||
id: string
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
hostname: string | null
|
||||
os: string | null
|
||||
services: Service[]
|
||||
suggested_type: string | null
|
||||
status: string
|
||||
discovery_source: string | null
|
||||
// All sources that have observed this device (e.g. ["arp", "proxmox"]). A
|
||||
// merged device shows under every matching filter. Falls back to
|
||||
// [discovery_source] when absent (older rows).
|
||||
discovery_sources?: string[]
|
||||
ieee_address?: string | null
|
||||
friendly_name?: string | null
|
||||
device_subtype?: string | null
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
// Display properties carried from discovery (e.g. Proxmox specs).
|
||||
properties?: NodeProperty[]
|
||||
discovered_at: string
|
||||
// How many canvases (designs) this device already appears on. Computed server-side.
|
||||
canvas_count?: number
|
||||
// Timestamps from the linked canvas node(s), correlated by ip/ieee_address.
|
||||
// Null/absent when the device is not on any canvas yet.
|
||||
node_created_at?: string | null
|
||||
node_last_scan?: string | null
|
||||
node_last_modified?: string | null
|
||||
node_last_seen?: string | null
|
||||
}
|
||||
// Home is `@/types` now — re-exported because most call sites import it here.
|
||||
export type { InventoryEntry } from '@/types'
|
||||
|
||||
interface InventoryDeviceModalProps {
|
||||
device: InventoryEntry | null
|
||||
@@ -51,20 +62,8 @@ interface InventoryDeviceModalProps {
|
||||
onApprove: (device: InventoryEntry) => void
|
||||
onHide: (device: InventoryEntry) => void
|
||||
onIgnore: (device: InventoryEntry) => void
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
switch: Network,
|
||||
generic: Circle,
|
||||
/** Called with the saved row after an edit, so the list can refresh in place. */
|
||||
onSaved?: (device: InventoryEntry) => void
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
@@ -81,134 +80,690 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
iot: '#e3b341',
|
||||
}
|
||||
|
||||
const CHECK_METHODS: CheckMethod[] = ['ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health', 'none']
|
||||
|
||||
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||
none: 'None',
|
||||
ping: 'Ping',
|
||||
http: 'HTTP',
|
||||
https: 'HTTPS',
|
||||
tcp: 'TCP',
|
||||
ssh: 'SSH',
|
||||
prometheus: 'Prometheus',
|
||||
health: 'Health',
|
||||
}
|
||||
|
||||
/** Live reachability — not the pending/approved/hidden lifecycle. */
|
||||
const LIVE_META: Record<string, { color: string; label: string }> = {
|
||||
online: { color: '#39d353', label: 'Online' },
|
||||
offline: { color: '#f85149', label: 'Offline' },
|
||||
pending: { color: '#e3b341', label: 'Checking' },
|
||||
unknown: { color: '#8b949e', label: 'Unknown' },
|
||||
}
|
||||
|
||||
const INPUT = 'bg-[#21262d] border-[#30363d] text-sm h-8'
|
||||
|
||||
function categoryColor(category: string | null | undefined) {
|
||||
if (!category) return '#8b949e'
|
||||
return CATEGORY_COLORS[category.toLowerCase()] ?? '#8b949e'
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
/** A section card: heading bar with its icon, optional action, body. */
|
||||
function Section({
|
||||
title,
|
||||
icon: Icon,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 text-xs">
|
||||
<span className="text-muted-foreground w-20 shrink-0">{label}</span>
|
||||
<span className="font-mono text-foreground break-all">{value}</span>
|
||||
<section className="rounded-lg border border-[#30363d] bg-[#161b22] overflow-hidden">
|
||||
<header className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[#30363d] bg-[#0d1117]/50">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Icon size={12} className="text-muted-foreground/70" aria-hidden />
|
||||
{title}
|
||||
</span>
|
||||
{action}
|
||||
</header>
|
||||
<div className="p-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ color, children, title }: { color: string; children: React.ReactNode; title?: string }) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={{ background: `${color}22`, color }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Copies a technical value; the row keeps its own confirmation state. */
|
||||
function CopyButton({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = () => {
|
||||
// `clipboard` is absent outside a secure context (and in jsdom), so the
|
||||
// promise itself is optional, not just the API.
|
||||
const written = navigator.clipboard?.writeText(value)
|
||||
if (!written) return
|
||||
written
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1200)
|
||||
})
|
||||
.catch(() => toast.error('Could not copy'))
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={copy}
|
||||
aria-label={`Copy ${label}`}
|
||||
className="ml-auto shrink-0 text-muted-foreground/50 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[#00d4ff] transition-opacity cursor-pointer"
|
||||
>
|
||||
{copied ? <Check size={11} className="text-[#39d353]" /> : <Copy size={11} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, copyable }: { label: string; value: string; copyable?: boolean }) {
|
||||
return (
|
||||
<div className="group flex items-baseline gap-3 py-1 border-b border-[#30363d]/40 last:border-b-0 text-xs">
|
||||
<span className="w-20 shrink-0 text-[11px] text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 font-mono text-foreground break-all">{value}</span>
|
||||
{copyable && <CopyButton label={label} value={value} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: InventoryDeviceModalProps) {
|
||||
/** A timestamp reads relative, with the exact value on hover. */
|
||||
function TimeRow({ label, iso }: { label: string; iso: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 py-1 border-b border-[#30363d]/40 last:border-b-0 text-xs" title={formatTimestamp(iso)}>
|
||||
<span className="w-20 shrink-0 text-[11px] text-muted-foreground">{label}</span>
|
||||
<span className="font-mono text-foreground/80">{formatRelative(iso)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs text-muted-foreground italic">{children}</p>
|
||||
}
|
||||
|
||||
function Field({ label, hint, children, className = '' }: { label: string; hint?: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 min-w-0 ${className}`}>
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
{hint && <span className="text-[10px] text-muted-foreground/50">{hint}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The editable subset of an inventory row, as strings for the form inputs. */
|
||||
interface EditForm {
|
||||
label: string
|
||||
type: string
|
||||
hostname: string
|
||||
ip: string
|
||||
mac: string
|
||||
os: string
|
||||
vendor: string
|
||||
model: string
|
||||
friendly_name: string
|
||||
device_subtype: string
|
||||
notes: string
|
||||
cpu_count: string
|
||||
cpu_model: string
|
||||
ram_gb: string
|
||||
disk_gb: string
|
||||
show_hardware: boolean
|
||||
check_method: string
|
||||
check_target: string
|
||||
}
|
||||
|
||||
function toForm(d: InventoryEntry): EditForm {
|
||||
return {
|
||||
label: d.label ?? '',
|
||||
// Falls back to the discovery guess, so a first edit adopts it rather than
|
||||
// silently clearing what the scanner suggested.
|
||||
type: d.type ?? d.suggested_type ?? '',
|
||||
hostname: d.hostname ?? '',
|
||||
ip: d.ip ?? '',
|
||||
mac: d.mac ?? '',
|
||||
os: d.os ?? '',
|
||||
vendor: d.vendor ?? '',
|
||||
model: d.model ?? '',
|
||||
friendly_name: d.friendly_name ?? '',
|
||||
device_subtype: d.device_subtype ?? '',
|
||||
notes: d.notes ?? '',
|
||||
cpu_count: d.cpu_count != null ? String(d.cpu_count) : '',
|
||||
cpu_model: d.cpu_model ?? '',
|
||||
ram_gb: d.ram_gb != null ? String(d.ram_gb) : '',
|
||||
disk_gb: d.disk_gb != null ? String(d.disk_gb) : '',
|
||||
show_hardware: d.show_hardware ?? false,
|
||||
check_method: d.check_method ?? '',
|
||||
check_target: d.check_target ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const nullable = (v: string) => (v.trim() === '' ? null : v.trim())
|
||||
const numeric = (v: string) => (v.trim() === '' ? null : Number(v))
|
||||
|
||||
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore, onSaved }: InventoryDeviceModalProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState<EditForm>(() => (device ? toForm(device) : toForm({} as InventoryEntry)))
|
||||
const [properties, setProperties] = useState<NodeProperty[]>(device?.properties ?? [])
|
||||
const [services, setServices] = useState<ServiceInfo[]>(device?.services ?? [])
|
||||
const [svcModal, setSvcModal] = useState<{ index: number | null; form?: ServiceFormData } | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
|
||||
// Reset the form whenever the modal is pointed at another device — the
|
||||
// component stays mounted across card clicks in the inventory grid.
|
||||
useEffect(() => {
|
||||
if (!device) return
|
||||
setForm(toForm(device))
|
||||
setProperties(device.properties ?? [])
|
||||
setServices(device.services ?? [])
|
||||
setEditing(false)
|
||||
setSvcModal(null)
|
||||
}, [device])
|
||||
|
||||
if (!device) return null
|
||||
|
||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const resolvedType = (device.type ?? device.suggested_type ?? 'generic') as NodeType
|
||||
const TypeIcon = NODE_TYPE_DEFAULT_ICONS[resolvedType] ?? NODE_TYPE_DEFAULT_ICONS.generic
|
||||
// Same accent the node wears on the canvas, so a device looks like itself.
|
||||
const roleColor = resolveNodeColors({ type: resolvedType, custom_colors: undefined }, activeTheme).border
|
||||
const isZigbee = device.discovery_source === 'zigbee'
|
||||
const rackOnly = isRackDevice(device)
|
||||
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||
const sources = orderedSources(device)
|
||||
const live = LIVE_META[device.status_live ?? 'unknown'] ?? LIVE_META.unknown
|
||||
const titleLabel = device.label ?? device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||
|
||||
const set = <K extends keyof EditForm>(key: K, value: EditForm[K]) => setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleApprove = () => { onApprove(device) }
|
||||
const handleHide = () => { onHide(device); onClose() }
|
||||
const handleIgnore = () => { onIgnore(device); onClose() }
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false)
|
||||
setForm(toForm(device))
|
||||
setProperties(device.properties ?? [])
|
||||
setServices(device.services ?? [])
|
||||
}
|
||||
|
||||
const handleSubmitService = (data: ServiceSubmitData) => {
|
||||
const svc: ServiceInfo = {
|
||||
...(data.port != null ? { port: data.port } : {}),
|
||||
protocol: data.protocol,
|
||||
service_name: data.service_name,
|
||||
...(data.path ? { path: data.path } : {}),
|
||||
...(data.host ? { host: data.host } : {}),
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
}
|
||||
setServices((prev) =>
|
||||
svcModal?.index == null ? [...prev, svc] : prev.map((s, i) => (i === svcModal.index ? { ...s, ...svc } : s))
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await scanApi.updatePending(device.id, {
|
||||
label: nullable(form.label),
|
||||
type: nullable(form.type),
|
||||
hostname: nullable(form.hostname),
|
||||
ip: nullable(form.ip),
|
||||
mac: nullable(form.mac),
|
||||
os: nullable(form.os),
|
||||
vendor: nullable(form.vendor),
|
||||
model: nullable(form.model),
|
||||
friendly_name: nullable(form.friendly_name),
|
||||
device_subtype: nullable(form.device_subtype),
|
||||
notes: nullable(form.notes),
|
||||
// Hardware is curated as properties, like every other spec — these five
|
||||
// are no longer editable here. They still carry Proxmox and YAML data,
|
||||
// so they go back untouched rather than being silently cleared.
|
||||
cpu_count: numeric(form.cpu_count),
|
||||
cpu_model: nullable(form.cpu_model),
|
||||
ram_gb: numeric(form.ram_gb),
|
||||
disk_gb: numeric(form.disk_gb),
|
||||
show_hardware: form.show_hardware,
|
||||
check_method: nullable(form.check_method) as CheckMethod | null,
|
||||
check_target: nullable(form.check_target),
|
||||
properties,
|
||||
services,
|
||||
})
|
||||
// Push the row straight into the canvas on screen. Its nodes carry a copy
|
||||
// of these facts, loaded before this edit — left stale, the next canvas
|
||||
// save (even one for a moved node) would report them as an edit and roll
|
||||
// the row back.
|
||||
useCanvasStore.getState().applyDeviceFacts(device.id, deviceFactsToNodeData(res.data))
|
||||
toast.success('Device updated')
|
||||
setEditing(false)
|
||||
onSaved?.(res.data)
|
||||
} catch {
|
||||
toast.error('Could not update device')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const timestamps: { label: string; iso: string }[] = []
|
||||
if (device.discovered_at) timestamps.push({ label: 'Discovered', iso: device.discovered_at })
|
||||
if (device.node_created_at) timestamps.push({ label: 'On canvas', iso: device.node_created_at })
|
||||
if (device.node_last_scan) timestamps.push({ label: 'Last scan', iso: device.node_last_scan })
|
||||
if (device.node_last_seen) timestamps.push({ label: 'Last seen', iso: device.node_last_seen })
|
||||
if (device.node_last_modified) timestamps.push({ label: 'Modified', iso: device.node_last_modified })
|
||||
|
||||
return (
|
||||
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
||||
{titleLabel}
|
||||
{isZigbee && (
|
||||
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
|
||||
Zigbee
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
{/* Device info */}
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
||||
{device.ip && <InfoRow label="IP" value={device.ip} />}
|
||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
|
||||
{device.friendly_name && device.friendly_name !== device.hostname && (
|
||||
<InfoRow label="Name" value={device.friendly_name} />
|
||||
)}
|
||||
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
|
||||
{device.model && <InfoRow label="Model" value={device.model} />}
|
||||
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
|
||||
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
|
||||
{device.suggested_type && (
|
||||
<InfoRow label="Type" value={device.suggested_type} />
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
|
||||
{!isZigbee && <div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Services found ({device.services.length})
|
||||
</p>
|
||||
{device.services.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No services detected</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{device.services.map((svc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded bg-[#21262d] border border-[#30363d] text-xs"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: categoryColor(svc.category) }}
|
||||
/>
|
||||
<span className="font-mono text-[#00d4ff] w-10 shrink-0">{svc.port}</span>
|
||||
<span className="text-muted-foreground w-7 shrink-0">{svc.protocol}</span>
|
||||
<span className="text-foreground truncate">{svc.service_name}</span>
|
||||
{svc.category && (
|
||||
<span className="ml-auto text-[10px] shrink-0" style={{ color: categoryColor(svc.category) }}>
|
||||
{svc.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DialogContent className="!max-w-none w-[min(1180px,95vw)] max-h-[90vh] p-0 gap-0 flex flex-col overflow-hidden bg-[#0d1117] border-[#30363d] text-foreground">
|
||||
{/* ── Header: who this device is, at a glance ── */}
|
||||
<DialogHeader className="shrink-0 px-5 py-4 border-b border-[#30363d] bg-[#161b22]">
|
||||
<div className="flex items-start gap-3 pr-10">
|
||||
<div
|
||||
className="w-11 h-11 shrink-0 rounded-lg border flex items-center justify-center"
|
||||
style={{ background: `${roleColor}18`, borderColor: `${roleColor}55`, color: roleColor }}
|
||||
>
|
||||
<TypeIcon size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="text-base font-semibold leading-tight break-all">{titleLabel}</DialogTitle>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{sources.map((s) => (
|
||||
<Chip key={s} color={SOURCE_META[s].color}>{SOURCE_META[s].label}</Chip>
|
||||
))}
|
||||
{(device.type ?? device.suggested_type) && (
|
||||
<Chip color={roleColor}>{NODE_TYPE_LABELS[resolvedType] ?? resolvedType}</Chip>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={{ background: `${live.color}22`, color: live.color }}
|
||||
title="Live reachability"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: live.color }} />
|
||||
{live.label}
|
||||
</span>
|
||||
{(device.canvas_count ?? 0) > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#00d4ff]/15 text-[#00d4ff]"
|
||||
title={`Drawn on ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
|
||||
>
|
||||
<Layers size={10} />
|
||||
{device.canvas_count}
|
||||
</span>
|
||||
)}
|
||||
{device.status === 'hidden' && <Chip color="#8b949e">Hidden</Chip>}
|
||||
{device.lqi != null && <Chip color="#8b949e">LQI {device.lqi}</Chip>}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
{/* Rack gear is mounted from a rack canvas, never approved onto a
|
||||
logical one — so it gets no Approve button at all. */}
|
||||
{!rackOnly && (
|
||||
</div>
|
||||
{!editing && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
variant="ghost"
|
||||
className={`shrink-0 gap-1.5 text-[#00d4ff] hover:text-[#00d4ff] hover:bg-[#00d4ff]/10 ${modalStyles['modal-interactive']}`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Approve
|
||||
<Pencil size={13} /> Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground hover:bg-[#30363d]"
|
||||
onClick={handleHide}
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex-1 text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
||||
onClick={handleIgnore}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Body: three columns — identity / operations / curation ── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-5">
|
||||
{editing ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
<Section title="Identity" icon={Fingerprint}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Name" className="col-span-2">
|
||||
<Input value={form.label} onChange={(e) => set('label', e.target.value)} placeholder="Display name" className={INPUT} />
|
||||
</Field>
|
||||
<Field label="Type" className="col-span-2">
|
||||
<Select value={form.type || undefined} onValueChange={(v) => set('type', (v as string | null) ?? '')}>
|
||||
<SelectTrigger className={`${INPUT} w-full cursor-pointer ${modalStyles['modal-interactive']}`} aria-label="Device type selector">
|
||||
<SelectValue placeholder="Pick a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{DEVICE_TYPE_GROUPS.map((group, i) => (
|
||||
<SelectGroup key={group.label}>
|
||||
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||
{group.label}
|
||||
</SelectLabel>
|
||||
{group.types.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm pl-4">
|
||||
{NODE_TYPE_LABELS[t] ?? t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Hostname">
|
||||
<Input value={form.hostname} onChange={(e) => set('hostname', e.target.value)} placeholder="server.lan" className={`${INPUT} font-mono`} />
|
||||
</Field>
|
||||
<Field label="IP" hint="comma-separated">
|
||||
<Input value={form.ip} onChange={(e) => set('ip', e.target.value)} placeholder="10.0.0.5, 10.0.1.5" className={`${INPUT} font-mono`} />
|
||||
</Field>
|
||||
<Field label="MAC">
|
||||
<Input value={form.mac} onChange={(e) => set('mac', e.target.value)} placeholder="aa:bb:cc:dd:ee:ff" className={`${INPUT} font-mono`} />
|
||||
</Field>
|
||||
<Field label="OS">
|
||||
<Input value={form.os} onChange={(e) => set('os', e.target.value)} placeholder="Debian 12" className={INPUT} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Make & model" icon={Factory}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Vendor">
|
||||
<Input value={form.vendor} onChange={(e) => set('vendor', e.target.value)} className={INPUT} />
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<Input value={form.model} onChange={(e) => set('model', e.target.value)} className={INPUT} />
|
||||
</Field>
|
||||
<Field label="Friendly name">
|
||||
<Input value={form.friendly_name} onChange={(e) => set('friendly_name', e.target.value)} className={INPUT} />
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<Input value={form.device_subtype} onChange={(e) => set('device_subtype', e.target.value)} placeholder="e.g. router" className={INPUT} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
<Section title="Monitoring" icon={HeartPulse}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Check method">
|
||||
<Select value={form.check_method || undefined} onValueChange={(v) => set('check_method', (v as string | null) ?? '')}>
|
||||
<SelectTrigger className={`${INPUT} w-full cursor-pointer ${modalStyles['modal-interactive']}`} aria-label="Check method selector">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{CHECK_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Check target">
|
||||
<Input value={form.check_target} onChange={(e) => set('check_target', e.target.value)} placeholder="host:port or URL" className={`${INPUT} font-mono`} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Notes" icon={StickyNote}>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Anything worth remembering about this device."
|
||||
className="bg-[#21262d] border-[#30363d] text-sm"
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
{/* Same editors as the canvas — one implementation, one behaviour. */}
|
||||
<section className="rounded-lg border border-[#30363d] bg-[#161b22] overflow-hidden">
|
||||
<PropertyList
|
||||
properties={properties}
|
||||
onChange={setProperties}
|
||||
visibleLabel="Show on node"
|
||||
// Hardware is a property like any other; these are the keys
|
||||
// the Proxmox import and the YAML import already mint.
|
||||
suggestions={['CPU Model', 'CPU Cores', 'RAM', 'Disk']}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Section
|
||||
title={`Services${services.length > 0 ? ` (${services.length})` : ''}`}
|
||||
icon={Network}
|
||||
action={
|
||||
<button
|
||||
onClick={() => setSvcModal({ index: null })}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{services.length === 0 ? (
|
||||
<Empty>No services — click Add to register one.</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{services.map((svc, i) => (
|
||||
<div key={`${svc.port ?? 'host'}-${svc.protocol}-${i}`} className="group flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-[#21262d] border border-[#30363d] text-xs">
|
||||
<span className="font-mono text-[#00d4ff] w-10 shrink-0">{svc.port ?? '—'}</span>
|
||||
<span className="text-muted-foreground w-8 shrink-0">{svc.protocol}</span>
|
||||
<span className="text-foreground truncate">{svc.service_name}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => setSvcModal({ index: i, form: serviceToForm(svc) })}
|
||||
title="Edit service"
|
||||
className="text-[#8b949e] hover:text-[#00d4ff] cursor-pointer"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setServices((prev) => prev.filter((_, j) => j !== i))}
|
||||
title="Remove service"
|
||||
className="text-[#8b949e] hover:text-[#f85149] cursor-pointer"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
|
||||
{/* What the device is: how to reach it, and what answers there. */}
|
||||
<div className="flex flex-col gap-4 min-w-0" data-testid="device-column-identity">
|
||||
<Section title="Identity" icon={Fingerprint}>
|
||||
<div className="flex flex-col">
|
||||
{device.ip && <InfoRow label="IP" value={device.ip} copyable />}
|
||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} copyable />}
|
||||
{device.mac && <InfoRow label="MAC" value={device.mac} copyable />}
|
||||
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} copyable />}
|
||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||
{(device.type ?? device.suggested_type) && (
|
||||
<InfoRow label="Type" value={(device.type ?? device.suggested_type) as string} />
|
||||
)}
|
||||
{device.friendly_name && device.friendly_name !== device.hostname && (
|
||||
<InfoRow label="Name" value={device.friendly_name} />
|
||||
)}
|
||||
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
|
||||
{device.model && <InfoRow label="Model" value={device.model} />}
|
||||
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
|
||||
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
|
||||
{!device.ip && !device.hostname && !device.mac && !device.ieee_address && (
|
||||
<Empty>Nothing identifying recorded yet.</Empty>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Zigbee devices have no IP services — the section would always be empty. */}
|
||||
{!isZigbee && (
|
||||
<Section title={`Services found (${device.services.length})`} icon={Network}>
|
||||
{device.services.length === 0 ? (
|
||||
<Empty>No services detected</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-72 overflow-y-auto">
|
||||
{device.services.map((svc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-[#21262d] border border-[#30363d] text-xs"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: categoryColor(svc.category) }}
|
||||
/>
|
||||
<span className="font-mono text-[#00d4ff] w-10 shrink-0">{svc.port}</span>
|
||||
<span className="text-muted-foreground w-8 shrink-0">{svc.protocol}</span>
|
||||
<span className="text-foreground truncate">{svc.service_name}</span>
|
||||
{svc.category && (
|
||||
<span className="ml-auto text-[10px] shrink-0" style={{ color: categoryColor(svc.category) }}>
|
||||
{svc.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* How it is watched, and what happened to it. */}
|
||||
<div className="flex flex-col gap-4 min-w-0" data-testid="device-column-operations">
|
||||
<Section title="Monitoring" icon={HeartPulse}>
|
||||
<div className="flex flex-col">
|
||||
{device.check_method ? (
|
||||
<InfoRow
|
||||
label="Check"
|
||||
value={device.check_target ? `${device.check_method} → ${device.check_target}` : device.check_method}
|
||||
/>
|
||||
) : (
|
||||
<Empty>Not monitored — pick a check method from Edit.</Empty>
|
||||
)}
|
||||
{device.response_time_ms != null && <InfoRow label="Response" value={`${device.response_time_ms} ms`} />}
|
||||
{device.discovery_source && <InfoRow label="Source" value={device.discovery_source.toUpperCase()} />}
|
||||
{device.canvas_count != null && device.canvas_count > 0 && (
|
||||
<InfoRow label="Canvases" value={String(device.canvas_count)} />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Activity" icon={History}>
|
||||
<div className="flex flex-col">
|
||||
{timestamps.length === 0 ? (
|
||||
<Empty>No activity recorded.</Empty>
|
||||
) : (
|
||||
timestamps.map((t) => <TimeRow key={t.label} label={t.label} iso={t.iso} />)
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Notes" icon={StickyNote}>
|
||||
{device.notes ? (
|
||||
<p className="text-xs text-foreground/80 whitespace-pre-wrap">{device.notes}</p>
|
||||
) : (
|
||||
<Empty>No notes.</Empty>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* What the user curated. */}
|
||||
<div className="flex flex-col gap-4 min-w-0" data-testid="device-column-curation">
|
||||
<Section
|
||||
title={`Properties${device.properties && device.properties.length > 0 ? ` (${device.properties.length})` : ''}`}
|
||||
icon={Tags}
|
||||
>
|
||||
{device.properties && device.properties.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{device.properties.map((prop, i) => (
|
||||
<div key={`${prop.key}-${i}`} className="flex items-baseline gap-2 px-2.5 py-1.5 rounded-md bg-[#21262d] border border-[#30363d] text-xs">
|
||||
<span className="text-muted-foreground shrink-0">{prop.key}</span>
|
||||
<span className="ml-auto font-mono text-foreground break-all text-right">{prop.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>No properties.</Empty>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer: lifecycle in view, form controls in edit ── */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 px-5 py-3 border-t border-[#30363d] bg-[#161b22]">
|
||||
<p className="text-[11px] text-muted-foreground/70 min-w-0 truncate">
|
||||
{editing
|
||||
? 'These facts belong to the device — every canvas drawing it follows.'
|
||||
: rackOnly
|
||||
? 'Rack gear — mounted from a rack canvas, never placed on a logical one.'
|
||||
: 'One device, one row. Editing here updates every canvas drawing it.'}
|
||||
</p>
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={handleCancel} className="text-muted-foreground hover:text-foreground">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 min-w-20"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-[#30363d]"
|
||||
onClick={handleHide}
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
||||
onClick={handleIgnore}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{/* Rack gear is mounted from a rack canvas, never approved onto a
|
||||
logical one — so it gets no Approve button at all. */}
|
||||
{!rackOnly && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{svcModal && (
|
||||
<ServiceModal
|
||||
key={`svc-${svcModal.index ?? 'new'}`}
|
||||
open
|
||||
onClose={() => setSvcModal(null)}
|
||||
onSubmit={handleSubmitService}
|
||||
initial={svcModal.form}
|
||||
title={svcModal.index === null ? 'Add Service' : 'Edit Service'}
|
||||
confirmLabel={svcModal.index === null ? 'Add' : 'Save'}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -12,19 +12,9 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { IconPickerPanel } from './IconPickerPanel'
|
||||
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||
{ label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] },
|
||||
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
||||
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
|
||||
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
||||
]
|
||||
import { MAX_HANDLES, MIN_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||
import { isValidParentNode } from '@/utils/virtualEdgeParent'
|
||||
import { NODE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
|
||||
|
||||
// Maps a side to its per-type default field on NodeTypeStyle.
|
||||
const SIDE_STYLE_KEY: Record<Side, keyof NodeTypeStyle> = {
|
||||
@@ -44,7 +34,7 @@ function CPStepper({ label, side, value, onChange }: {
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const min = sideDefault(side)
|
||||
const min = MIN_HANDLES
|
||||
const labelEl = <span className="text-[10px] text-muted-foreground/80 leading-none">{label}</span>
|
||||
const belowLabel = side === 'bottom'
|
||||
const btn = 'w-6 h-full flex items-center justify-center text-sm text-muted-foreground hover:text-foreground hover:bg-[#21262d] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-default'
|
||||
@@ -174,15 +164,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
setLabelError(false)
|
||||
const selectedType = (form.type ?? 'generic') as NodeType
|
||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||
const validParentTypes = getValidParentTypes(selectedType)
|
||||
// A parent is valid either by the type rules (lxc/vm/docker_container) or
|
||||
// because the candidate is a container-mode node (any child can nest in it).
|
||||
const isValidParent = (p: ParentCandidate) =>
|
||||
validParentTypes.includes(p.type) || p.container_mode === true
|
||||
let safeParentId = form.parent_id
|
||||
if (safeParentId) {
|
||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||
if (!parent || !isValidParentNode(selectedType, parent)) safeParentId = undefined
|
||||
}
|
||||
const isGroupType = selectedType === 'groupRect' || selectedType === 'group'
|
||||
onSubmit({
|
||||
@@ -225,7 +210,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
// Drop the parent only if it's no longer a valid target for the
|
||||
// new type — keep container-mode parents (any node can nest).
|
||||
const parent = parentCandidates.find((n) => n.id === f.parent_id)
|
||||
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
|
||||
if (f.parent_id && !(parent && isValidParentNode(t, parent))) {
|
||||
next.parent_id = undefined
|
||||
}
|
||||
return next
|
||||
@@ -377,13 +362,13 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
{/* Parent Container */}
|
||||
{(() => {
|
||||
const childType = (form.type ?? 'generic') as NodeType
|
||||
const validParentTypes = getValidParentTypes(childType)
|
||||
// Candidates: type-based parents (lxc/vm/docker_container) plus any
|
||||
// container-mode node. The current parent is always kept so an
|
||||
// already-nested node can be re-targeted or detached here.
|
||||
// Candidates: type-based parents (lxc/vm/docker_container), any
|
||||
// container-mode node, and visual groups. The current parent is
|
||||
// always kept so an already-nested node can be re-targeted or
|
||||
// detached here.
|
||||
const validParents = parentCandidates.filter(
|
||||
(n) => n.id !== currentNodeId &&
|
||||
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
|
||||
(isValidParentNode(childType, n) || n.id === form.parent_id),
|
||||
)
|
||||
if (validParents.length === 0) return null
|
||||
return (
|
||||
|
||||
@@ -45,11 +45,13 @@ export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Se
|
||||
const port = trimmedPort === '' ? undefined : Number.parseInt(trimmedPort, 10)
|
||||
if (port != null && (Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
const path = form.path.trim()
|
||||
const hostOverride = form.host.trim()
|
||||
onSubmit({
|
||||
service_name: name,
|
||||
protocol: form.protocol,
|
||||
port,
|
||||
path: path || undefined,
|
||||
host: hostOverride || undefined,
|
||||
icon: form.icon,
|
||||
})
|
||||
onClose()
|
||||
@@ -134,6 +136,20 @@ export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Se
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host override */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Host</Label>
|
||||
<Input
|
||||
value={form.host}
|
||||
onChange={(e) => set('host', e.target.value)}
|
||||
placeholder="Node host (app.example.com)"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
Overrides the node host for this service only.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Path</Label>
|
||||
|
||||
@@ -693,3 +693,70 @@ describe('DeviceInventoryModal', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A device documented straight on a canvas carries a curated `label` and `type`
|
||||
* and no discovery guess at all — the card has to read those, or a printer
|
||||
* shows up unnamed with the generic circle.
|
||||
*/
|
||||
describe('DeviceInventoryModal — the curated type wins over the discovery guess', () => {
|
||||
const DEVICE_CANVAS = {
|
||||
id: 'dev-printer',
|
||||
ip: '192.168.1.77',
|
||||
hostname: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
label: 'Office printer',
|
||||
type: 'printer',
|
||||
suggested_type: null,
|
||||
status: 'approved',
|
||||
discovery_source: 'canvas',
|
||||
discovery_sources: ['canvas'],
|
||||
discovered_at: '2026-02-01T00:00:00Z',
|
||||
}
|
||||
|
||||
it('names the card from the curated label', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS] })
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
|
||||
expect(screen.getByText('Office printer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('badges the curated type', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS] })
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
|
||||
expect(within(screen.getByTestId('pending-card-dev-printer')).getByText('printer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers it over a stale discovery guess', async () => {
|
||||
mockPending.mockResolvedValue({ data: [{ ...DEVICE_CANVAS, suggested_type: 'server' }] })
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
|
||||
const card = within(screen.getByTestId('pending-card-dev-printer'))
|
||||
expect(card.getByText('printer')).toBeInTheDocument()
|
||||
expect(card.queryByText('server')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters on it', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS, DEVICE_IP] })
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'printer' } })
|
||||
expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('approves onto a node of that type', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS] })
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-printer'))
|
||||
fireEvent.click(screen.getByTestId('do-approve'))
|
||||
await waitFor(() => expect(mockApprove).toHaveBeenCalled())
|
||||
expect(mockApprove.mock.calls[0][1]).toMatchObject({ type: 'printer', label: 'Office printer' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Edit mode of the device detail modal — the write half of "the inventory row
|
||||
* owns the device facts".
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { InventoryDeviceModal } from '../InventoryDeviceModal'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
const mockUpdatePending = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
updatePending: (...a: unknown[]) => mockUpdatePending(...a),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
|
||||
|
||||
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
|
||||
return {
|
||||
id: 'dev-1',
|
||||
ip: '192.168.1.100',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
hostname: 'pve.local',
|
||||
os: 'Linux',
|
||||
services: [],
|
||||
suggested_type: 'server',
|
||||
status: 'pending',
|
||||
discovered_at: '2024-01-15T10:30:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const noop = { onClose: vi.fn(), onApprove: vi.fn(), onHide: vi.fn(), onIgnore: vi.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdatePending.mockResolvedValue({ data: makeDevice({ label: 'Saved' }) })
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — view mode extras', () => {
|
||||
it('shows the curated fields the inventory row now owns', () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({
|
||||
notes: 'garage rack',
|
||||
cpu_count: 4,
|
||||
ram_gb: 16,
|
||||
check_method: 'http',
|
||||
check_target: 'http://192.168.1.100',
|
||||
properties: [{ key: 'Owner', value: 'me', icon: null, visible: true }],
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('garage rack')).toBeInTheDocument()
|
||||
expect(screen.getByText('http → http://192.168.1.100')).toBeInTheDocument()
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('me')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers the curated label and type over the discovery guesses', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ label: 'Big NAS', type: 'nas' })} />)
|
||||
expect(screen.getAllByText('Big NAS').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('nas')).toBeInTheDocument()
|
||||
expect(screen.queryByText('server')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — edit mode', () => {
|
||||
it('switches to the form on Edit and back on Cancel', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
// Lifecycle buttons belong to the view; the form has Save / Cancel.
|
||||
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('seeds the form from the row, falling back to the discovery type', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ label: null, type: null })} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect((screen.getByDisplayValue('pve.local') as HTMLInputElement).value).toBe('pve.local')
|
||||
expect(screen.getByDisplayValue('192.168.1.100')).toBeInTheDocument()
|
||||
|
||||
// The untouched type select adopts `suggested_type` rather than clearing it.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
expect(mockUpdatePending.mock.calls[0][1].type).toBe('server')
|
||||
})
|
||||
|
||||
it('PATCHes the edited fields and reports the saved row', async () => {
|
||||
const onSaved = vi.fn()
|
||||
render(<InventoryDeviceModal {...noop} onSaved={onSaved} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Display name'), { target: { value: 'Big NAS' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
const [id, body] = mockUpdatePending.mock.calls[0]
|
||||
expect(id).toBe('dev-1')
|
||||
expect(body.label).toBe('Big NAS')
|
||||
expect(body.hostname).toBe('pve.local')
|
||||
expect(body.properties).toEqual([])
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({ label: 'Saved' })))
|
||||
})
|
||||
|
||||
it('sends null rather than an empty string for a cleared field', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByDisplayValue('192.168.1.100'), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
expect(mockUpdatePending.mock.calls[0][1].ip).toBeNull()
|
||||
})
|
||||
|
||||
// Hardware moved to the properties; the columns are no longer edited here but
|
||||
// must still be sent as numbers, not as the form's strings.
|
||||
it('sends the numeric hardware fields as numbers', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ cpu_count: 2, ram_gb: 8 })} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
const body = mockUpdatePending.mock.calls[0][1]
|
||||
expect(body.ram_gb).toBe(8)
|
||||
expect(body.cpu_count).toBe(2)
|
||||
})
|
||||
|
||||
it('edits properties through the shared PropertyList', async () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({ properties: [{ key: 'Rack', value: 'A1', icon: null, visible: true }] })}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
// The shared editor renders the existing property with its remove control.
|
||||
expect(screen.getByText('Rack')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTitle('Remove property'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
expect(mockUpdatePending.mock.calls[0][1].properties).toEqual([])
|
||||
})
|
||||
|
||||
it('removes a service from the editable list', async () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({
|
||||
services: [
|
||||
{ port: 80, protocol: 'tcp', service_name: 'HTTP' },
|
||||
{ port: 443, protocol: 'tcp', service_name: 'HTTPS' },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.click(screen.getAllByTitle('Remove service')[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
expect(mockUpdatePending.mock.calls[0][1].services).toEqual([
|
||||
{ port: 443, protocol: 'tcp', service_name: 'HTTPS' },
|
||||
])
|
||||
})
|
||||
|
||||
it('stays in edit mode when the save fails', async () => {
|
||||
mockUpdatePending.mockRejectedValueOnce(new Error('boom'))
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reseeds the form when pointed at another device', () => {
|
||||
const { rerender } = render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByDisplayValue('pve.local')).toBeInTheDocument()
|
||||
|
||||
rerender(<InventoryDeviceModal {...noop} device={makeDevice({ id: 'dev-2', hostname: 'other.local' })} />)
|
||||
// Back to the view of the new device, not the previous one's open form.
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
|
||||
expect(screen.getAllByText('other.local').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* The detail modal reads as a device sheet: a header that identifies the row at
|
||||
* a glance, sections that stay put when they have nothing to show, and copyable
|
||||
* technical values.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { InventoryDeviceModal } from '../InventoryDeviceModal'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
const mockUpdatePending = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: { updatePending: (...a: unknown[]) => mockUpdatePending(...a) },
|
||||
}))
|
||||
|
||||
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
|
||||
|
||||
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
|
||||
return {
|
||||
id: 'dev-1',
|
||||
ip: '192.168.1.100',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
hostname: 'pve.local',
|
||||
os: 'Linux',
|
||||
services: [],
|
||||
suggested_type: 'server',
|
||||
status: 'pending',
|
||||
discovered_at: '2024-01-15T10:30:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const noop = { onClose: vi.fn(), onApprove: vi.fn(), onHide: vi.fn(), onIgnore: vi.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdatePending.mockResolvedValue({ data: makeDevice() })
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — header', () => {
|
||||
it('badges every source that observed the device', () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
// No `ip`, so the only "IP" on screen is the source badge itself.
|
||||
device={makeDevice({ ip: null, discovery_source: 'arp', discovery_sources: ['arp', 'proxmox'] })}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('IP')).toBeInTheDocument()
|
||||
expect(screen.getByText('PROXMOX')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows live reachability, which is not the pending/approved lifecycle', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ status: 'pending', status_live: 'online' })} />)
|
||||
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reads unknown when the device has never been checked', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts the canvases drawing the device', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ canvas_count: 3 })} />)
|
||||
expect(screen.getByTitle('Drawn on 3 canvases')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks a hidden row', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ status: 'hidden' })} />)
|
||||
expect(screen.getByText('Hidden')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — view sections', () => {
|
||||
// What answers on a host belongs beside how to reach it, not in the curation
|
||||
// column next to the properties.
|
||||
it('puts the services under Identity', () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({ services: [{ port: 22, protocol: 'tcp', service_name: 'SSH' }] })}
|
||||
/>
|
||||
)
|
||||
const identity = screen.getByTestId('device-column-identity')
|
||||
expect(identity).toHaveTextContent('Identity')
|
||||
expect(identity).toHaveTextContent('Services found (1)')
|
||||
expect(screen.getByTestId('device-column-curation')).not.toHaveTextContent('Services found')
|
||||
})
|
||||
|
||||
it('heads every section with an icon', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
const headings = [...screen.getByRole('dialog').querySelectorAll('section > header > span:first-child')]
|
||||
expect(headings.length).toBeGreaterThan(0)
|
||||
for (const heading of headings) {
|
||||
expect(heading.querySelector('svg')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a section with an empty-state rather than dropping it', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
expect(screen.getByText('No properties.')).toBeInTheDocument()
|
||||
expect(screen.getByText('No notes.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Not monitored — pick a check method from Edit.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Hardware is curated as properties, like every other spec — the modal has no
|
||||
// hardware section on either side.
|
||||
it('shows no hardware section — specs live in the properties', () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({ cpu_count: 4, ram_gb: 8, cpu_model: 'N5105', disk_gb: 500 })}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
expect(screen.queryByText('4 vCPU · N5105 · 8 GB RAM · 500 GB disk')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers no hardware inputs in the form', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ ram_gb: 8, show_hardware: true })} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
expect(screen.queryByText('RAM (GB)')).toBeNull()
|
||||
expect(screen.queryByText('Show hardware specs on the node')).toBeNull()
|
||||
})
|
||||
|
||||
it('suggests the spec keys so a hardware value is one click from a property', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByTitle('Add a RAM property')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Add a CPU Model property')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Proxmox and the YAML import still write these columns; an edit here must not
|
||||
// wipe what they recorded.
|
||||
it('sends the stored hardware values back untouched', async () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({ cpu_count: 4, cpu_model: 'N5105', ram_gb: 8, disk_gb: 500, show_hardware: true })}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
const body = mockUpdatePending.mock.calls[0][1]
|
||||
expect(body).toMatchObject({
|
||||
cpu_count: 4,
|
||||
cpu_model: 'N5105',
|
||||
ram_gb: 8,
|
||||
disk_gb: 500,
|
||||
show_hardware: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the response time of the last check', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice({ check_method: 'ping', response_time_ms: 12 })} />)
|
||||
expect(screen.getByText('12 ms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies a technical value to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, { clipboard: { writeText } })
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Copy IP'))
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('192.168.1.100'))
|
||||
})
|
||||
|
||||
it('offers no copy button on a value that is not an address', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
expect(screen.queryByLabelText('Copy OS')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — edit sections', () => {
|
||||
it('edits the make-and-model fields the view shows', async () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({ vendor: 'Dell', friendly_name: 'kitchen', device_subtype: 'router' })}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByDisplayValue('kitchen'), { target: { value: 'garage' } })
|
||||
fireEvent.change(screen.getByDisplayValue('router'), { target: { value: 'switch' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
|
||||
const body = mockUpdatePending.mock.calls[0][1]
|
||||
expect(body.friendly_name).toBe('garage')
|
||||
expect(body.device_subtype).toBe('switch')
|
||||
expect(body.vendor).toBe('Dell')
|
||||
})
|
||||
|
||||
it('drops the edits on Cancel rather than keeping them staged', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByDisplayValue('pve.local'), { target: { value: 'renamed.local' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByDisplayValue('pve.local')).toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('renamed.local')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the lifecycle actions while editing', () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.queryByRole('button', { name: 'Hide' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull()
|
||||
// …and offers no second Edit while the form is open.
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -288,7 +288,7 @@ describe('NodeModal', () => {
|
||||
// ── Container mode ─────────────────────────────────────────────────────
|
||||
|
||||
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
|
||||
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
|
||||
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'kvm', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
|
||||
|
||||
it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
@@ -424,6 +424,42 @@ describe('NodeModal', () => {
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a group parent_id on submit for a plain node', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'g1' },
|
||||
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('g1')
|
||||
})
|
||||
|
||||
it('keeps a group parent_id when the type changes', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'g1' },
|
||||
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
|
||||
})
|
||||
fireEvent.change(selects()[0], { target: { value: 'nas' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('g1')
|
||||
})
|
||||
|
||||
it('offers a group as a parent option for a plain node', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'server' },
|
||||
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
|
||||
})
|
||||
const option = screen.getByRole('option', { name: 'My Group' }) as HTMLOptionElement
|
||||
expect(option.value).toBe('g1')
|
||||
})
|
||||
|
||||
it('does not offer a group as a parent for another group', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'group' },
|
||||
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
|
||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||
@@ -507,15 +543,29 @@ describe('NodeModal', () => {
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).right_handles).toBe(2)
|
||||
})
|
||||
|
||||
it('disables the − button at the side minimum (0 for left/right, 1 for top/bottom)', () => {
|
||||
it('disables the − button at 0, the minimum for every side', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect((screen.getByRole('button', { name: 'Decrease Left connection points' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
// Top starts at its default of 1, so it can still go down to 0.
|
||||
expect((screen.getByRole('button', { name: 'Decrease Top connection points' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Decrease Top connection points' }))
|
||||
expect((screen.getByRole('button', { name: 'Decrease Top connection points' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('left/right min is 0, top/bottom min is 1; max is 64 everywhere', () => {
|
||||
it('takes top/bottom down to 0 connection points', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(screen.getByLabelText('Top connection points'), { target: { value: '0' } })
|
||||
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '0' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const submitted = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(submitted.top_handles).toBe(0)
|
||||
expect(submitted.bottom_handles).toBe(0)
|
||||
})
|
||||
|
||||
it('min is 0 on every side; max is 64 everywhere', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).min).toBe('1')
|
||||
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).min).toBe('0')
|
||||
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).min).toBe('0')
|
||||
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).min).toBe('0')
|
||||
for (const label of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||
expect((screen.getByLabelText(`${label} connection points`) as HTMLInputElement).max).toBe('64')
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('ServiceModal', () => {
|
||||
protocol: 'udp',
|
||||
port: 80,
|
||||
path: '/admin',
|
||||
host: undefined,
|
||||
icon: undefined,
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
@@ -117,6 +118,34 @@ describe('ServiceModal', () => {
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ path: undefined }))
|
||||
})
|
||||
|
||||
it('prefills and clears the host override', () => {
|
||||
const withHost = serviceToForm({ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com' })
|
||||
const { onSubmit } = setup({ initial: withHost, confirmLabel: 'Save' })
|
||||
const input = screen.getByPlaceholderText('Node host (app.example.com)') as HTMLInputElement
|
||||
expect(input.value).toBe('blog.example.com')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: undefined }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('host override', () => {
|
||||
it('submits a trimmed host override', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: ' blog.example.com ' } })
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: 'blog.example.com' }))
|
||||
})
|
||||
|
||||
it('submits undefined when the host is left blank', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: ' ' } })
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: undefined }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon picker', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createElement, useRef, useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical } from 'lucide-react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical, Boxes } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
@@ -16,12 +16,15 @@ import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
/** Opens the Device Inventory on this node's row. Absent in contexts with no
|
||||
* inventory (standalone, the tour), where the link is simply not offered. */
|
||||
onOpenInventory?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
/** Open service editor: `index === null` means "add a new service". */
|
||||
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
export function DetailPanel({ onEdit, onOpenInventory }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
|
||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||
|
||||
@@ -102,6 +105,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
protocol: data.protocol,
|
||||
service_name: data.service_name,
|
||||
...(data.path ? { path: data.path } : {}),
|
||||
...(data.host ? { host: data.host } : {}),
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
}
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
@@ -115,6 +119,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
service_name: data.service_name,
|
||||
port: data.port,
|
||||
path: data.path,
|
||||
host: data.host,
|
||||
icon: data.icon,
|
||||
}
|
||||
: svc
|
||||
@@ -207,6 +212,16 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.last_scan && <DetailRow label="Last Scan" value={formatTimestamp(data.last_scan)} />}
|
||||
{data.created_at && <DetailRow label="Created" value={formatTimestamp(data.created_at)} />}
|
||||
{data.updated_at && <DetailRow label="Last Modified" value={formatTimestamp(data.updated_at)} />}
|
||||
{/* The row behind this node — where the same facts live for every other
|
||||
canvas showing it. */}
|
||||
{data.device_id && onOpenInventory && (
|
||||
<button
|
||||
onClick={() => onOpenInventory(data.device_id as string)}
|
||||
className="mt-1 flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Boxes size={10} /> Open in inventory
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Size section — manual width/height entry for pixel-exact sizing */}
|
||||
@@ -233,7 +248,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
|
||||
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol, svc.host)]}
|
||||
draggable={services.length > 1}
|
||||
isDragging={dragSvcIndex === i}
|
||||
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
|
||||
|
||||
@@ -388,14 +388,25 @@ function VersionBadge() {
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
<span className="text-[11px] text-muted-foreground">-</span>
|
||||
<a
|
||||
href="https://homelable.net/changelog/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
||||
>
|
||||
Changelog
|
||||
</a>
|
||||
</div>
|
||||
{hasUpdate && latest && (
|
||||
<a
|
||||
href={latest.url.startsWith('https://') ? latest.url : '#'}
|
||||
|
||||
@@ -137,9 +137,9 @@ describe('DetailPanel', () => {
|
||||
fireEvent.click(addButtons[0]) // first Add = properties
|
||||
// Form is now open — fill key and value
|
||||
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'RTX 4090' } })
|
||||
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/^Value/), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
|
||||
@@ -243,7 +243,7 @@ describe('DetailPanel', () => {
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[0])
|
||||
// Only fill value, leave key empty
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'some value' } })
|
||||
const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1])
|
||||
expect(updateNode).not.toHaveBeenCalled()
|
||||
@@ -445,6 +445,46 @@ describe('DetailPanel', () => {
|
||||
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stores a host override on the added service and links it', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ ip: '192.168.1.10' })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '443' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: 'blog.example.com' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'blog', host: 'blog.example.com' })
|
||||
})
|
||||
|
||||
it('links a service badge to its host override rather than the node IP', () => {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ ip: '192.168.1.10', services: [{ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com' }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: 'blog' }).getAttribute('href')).toBe('https://blog.example.com:443')
|
||||
})
|
||||
|
||||
it('calls updateNode without the removed service when X is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||
@@ -744,7 +784,7 @@ describe('DetailPanel', () => {
|
||||
it('paints a service red when its live status is offline', () => {
|
||||
setupStore(
|
||||
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||
{ 'n1:8080/tcp': 'offline' },
|
||||
{ 'n1:8080/tcp@': 'offline' },
|
||||
)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
|
||||
@@ -753,11 +793,27 @@ describe('DetailPanel', () => {
|
||||
it('keeps the category colour when the live status is online', () => {
|
||||
setupStore(
|
||||
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||
{ 'n1:8080/tcp': 'online' },
|
||||
{ 'n1:8080/tcp@': 'online' },
|
||||
)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||
})
|
||||
|
||||
it('paints only the offline vhost red when two services share a port', () => {
|
||||
setupStore(
|
||||
{
|
||||
ip: '192.168.1.10',
|
||||
services: [
|
||||
{ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com', path: '' },
|
||||
{ port: 443, protocol: 'tcp', service_name: 'shop', host: 'shop.example.com', path: '' },
|
||||
],
|
||||
},
|
||||
{ 'n1:443/tcp@shop.example.com': 'offline' },
|
||||
)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: 'blog' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||
expect(screen.getByRole('link', { name: 'shop' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
|
||||
})
|
||||
})
|
||||
|
||||
describe('Last Seen formatting', () => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The panel offers a way through to the Device Inventory row behind the node —
|
||||
* the place the same facts live for every other canvas showing this device.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DetailPanel } from '../DetailPanel'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
vi.mock('@/stores/canvasStore', async (importActual) => ({
|
||||
...(await importActual<typeof canvasStore>()),
|
||||
useCanvasStore: vi.fn(),
|
||||
}))
|
||||
|
||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: 'NAS', type: 'server', status: 'online', services: [], ...data },
|
||||
}
|
||||
}
|
||||
|
||||
function setupStore(nodeData: Partial<NodeData> = {}) {
|
||||
const state = {
|
||||
nodes: [makeNode(nodeData)],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
removeFromGroup: vi.fn(),
|
||||
setNodeSize: vi.fn(),
|
||||
serviceStatuses: {},
|
||||
}
|
||||
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('DetailPanel — inventory link', () => {
|
||||
it('opens the inventory on the linked device', () => {
|
||||
setupStore({ device_id: 'dev-1' })
|
||||
const onOpenInventory = vi.fn()
|
||||
render(<DetailPanel onEdit={vi.fn()} onOpenInventory={onOpenInventory} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open in inventory/i }))
|
||||
expect(onOpenInventory).toHaveBeenCalledWith('dev-1')
|
||||
})
|
||||
|
||||
it('offers nothing for a node with no row yet', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} onOpenInventory={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: /Open in inventory/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers nothing where there is no inventory to open (standalone)', () => {
|
||||
setupStore({ device_id: 'dev-1' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: /Open in inventory/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -83,6 +83,13 @@ describe('VersionBadge', () => {
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('links to the public changelog next to the version', () => {
|
||||
renderSidebar()
|
||||
const link = screen.getByText('Changelog').closest('a')
|
||||
expect(link).toHaveAttribute('href', 'https://homelable.net/changelog/')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('does not show update badge when on latest version', () => {
|
||||
renderSidebar()
|
||||
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { useAlignmentGuides } from '../useAlignmentGuides'
|
||||
|
||||
const nodesRef: { current: Node<NodeData>[] } = { current: [] }
|
||||
const setNodes = vi.fn()
|
||||
|
||||
vi.mock('@xyflow/react', async () => (await import('@/test/mocks')).mockReactFlow({
|
||||
useReactFlow: () => ({
|
||||
getNodes: () => nodesRef.current,
|
||||
setNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeFlowNode(
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
over: Partial<Node<NodeData>> = {},
|
||||
): Node<NodeData> {
|
||||
return {
|
||||
id,
|
||||
type: 'custom',
|
||||
position: { x, y },
|
||||
data: { label: id, type: 'server' } as NodeData,
|
||||
measured: { width: 100, height: 50 },
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const evt = {} as MouseEvent
|
||||
|
||||
describe('useAlignmentGuides — parented nodes', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setNodes.mockClear()
|
||||
nodesRef.current = []
|
||||
})
|
||||
|
||||
it('emits guides for a node dragged inside a group (parent-relative coords)', () => {
|
||||
// Group at (500, 500); two children in parent-relative coordinates.
|
||||
// Child "b" sits 3px off "a" horizontally → within the default threshold.
|
||||
nodesRef.current = [
|
||||
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
|
||||
makeFlowNode('a', 20, 20, { parentId: 'group' }),
|
||||
makeFlowNode('b', 23, 200, { parentId: 'group' }),
|
||||
]
|
||||
const { result } = renderHook(() => useAlignmentGuides())
|
||||
|
||||
act(() => {
|
||||
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
|
||||
})
|
||||
|
||||
// Guides are in absolute canvas space: group.x + a.x = 520.
|
||||
const xGuides = result.current.guides.filter((g) => g.axis === 'x')
|
||||
expect(xGuides.length).toBeGreaterThan(0)
|
||||
expect(xGuides.map((g) => g.position)).toContain(520)
|
||||
})
|
||||
|
||||
it('snaps the dragged child on drag stop, keeping its parent-relative position', () => {
|
||||
nodesRef.current = [
|
||||
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
|
||||
makeFlowNode('a', 20, 20, { parentId: 'group' }),
|
||||
makeFlowNode('b', 23, 200, { parentId: 'group' }),
|
||||
]
|
||||
const { result } = renderHook(() => useAlignmentGuides())
|
||||
|
||||
act(() => {
|
||||
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
|
||||
result.current.onNodeDragStop(evt, nodesRef.current[2], [nodesRef.current[2]])
|
||||
})
|
||||
|
||||
expect(setNodes).toHaveBeenCalledTimes(1)
|
||||
const updated = setNodes.mock.calls[0][0](nodesRef.current) as Node<NodeData>[]
|
||||
// -3 applied to the relative position: 23 → 20, absolute 523 → 520.
|
||||
expect(updated.find((n) => n.id === 'b')!.position.x).toBe(20)
|
||||
expect(updated.find((n) => n.id === 'a')!.position.x).toBe(20)
|
||||
})
|
||||
|
||||
it('aligns a child against a top-level node outside its group', () => {
|
||||
nodesRef.current = [
|
||||
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
|
||||
makeFlowNode('outside', 520, 50),
|
||||
makeFlowNode('child', 24, 100, { parentId: 'group' }),
|
||||
]
|
||||
const { result } = renderHook(() => useAlignmentGuides())
|
||||
|
||||
act(() => {
|
||||
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
|
||||
})
|
||||
|
||||
expect(result.current.guides.some((g) => g.axis === 'x' && g.position === 520)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves nested container chains (container in a group)', () => {
|
||||
nodesRef.current = [
|
||||
makeFlowNode('group', 100, 100, { type: 'group', measured: { width: 600, height: 600 } }),
|
||||
makeFlowNode('host', 50, 50, { parentId: 'group', measured: { width: 300, height: 300 } }),
|
||||
makeFlowNode('vm1', 10, 10, { parentId: 'host' }),
|
||||
makeFlowNode('vm2', 14, 120, { parentId: 'host' }),
|
||||
]
|
||||
const { result } = renderHook(() => useAlignmentGuides())
|
||||
|
||||
act(() => {
|
||||
result.current.onNodeDrag(evt, nodesRef.current[3], [nodesRef.current[3]])
|
||||
})
|
||||
|
||||
// vm1 absolute x = 100 + 50 + 10 = 160.
|
||||
expect(result.current.guides.some((g) => g.axis === 'x' && g.position === 160)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not snap a child whose parent is dragged too (no double shift)', () => {
|
||||
nodesRef.current = [
|
||||
makeFlowNode('group', 497, 500, { type: 'group', measured: { width: 400, height: 400 } }),
|
||||
makeFlowNode('child', 23, 20, { parentId: 'group' }),
|
||||
makeFlowNode('other', 500, 1200),
|
||||
]
|
||||
const { result } = renderHook(() => useAlignmentGuides())
|
||||
const dragged = [nodesRef.current[0], nodesRef.current[1]]
|
||||
|
||||
act(() => {
|
||||
result.current.onNodeDrag(evt, dragged[0], dragged)
|
||||
result.current.onNodeDragStop(evt, dragged[0], dragged)
|
||||
})
|
||||
|
||||
const updated = setNodes.mock.calls[0][0](nodesRef.current) as Node<NodeData>[]
|
||||
// The group snaps by +3; the child follows it, so its relative x is untouched.
|
||||
expect(updated.find((n) => n.id === 'group')!.position.x).toBe(500)
|
||||
expect(updated.find((n) => n.id === 'child')!.position.x).toBe(23)
|
||||
})
|
||||
|
||||
it('ignores nodes that follow the drag as alignment candidates', () => {
|
||||
// Only the group is dragged; its child must not act as a candidate for itself.
|
||||
nodesRef.current = [
|
||||
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
|
||||
makeFlowNode('child', 2, 20, { parentId: 'group' }),
|
||||
]
|
||||
const { result } = renderHook(() => useAlignmentGuides())
|
||||
|
||||
act(() => {
|
||||
result.current.onNodeDrag(evt, nodesRef.current[0], [nodesRef.current[0]])
|
||||
})
|
||||
|
||||
expect(result.current.guides).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -113,22 +113,25 @@ describe('useStatusPolling', () => {
|
||||
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
|
||||
})
|
||||
|
||||
it('calls setNodeStatus with correct data on status message', () => {
|
||||
it('lights up every node drawing the checked device', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
node_id: 'node-1',
|
||||
device_id: 'dev-1',
|
||||
node_ids: ['node-1', 'node-2'],
|
||||
status: 'online',
|
||||
checked_at: '2024-01-01T12:00:00Z',
|
||||
response_time_ms: 42,
|
||||
}),
|
||||
})
|
||||
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
|
||||
const expected = {
|
||||
status: 'online',
|
||||
response_time_ms: 42,
|
||||
last_seen: '2024-01-01T12:00:00Z',
|
||||
})
|
||||
}
|
||||
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', expected)
|
||||
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-2', expected)
|
||||
})
|
||||
|
||||
it('sets last_seen to undefined when status is offline', () => {
|
||||
@@ -136,7 +139,8 @@ describe('useStatusPolling', () => {
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
node_id: 'node-1',
|
||||
device_id: 'dev-1',
|
||||
node_ids: ['node-1'],
|
||||
status: 'offline',
|
||||
checked_at: '2024-01-01T12:00:00Z',
|
||||
}),
|
||||
@@ -152,7 +156,9 @@ describe('useStatusPolling', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
|
||||
data: JSON.stringify({
|
||||
device_id: 'dev-1', node_ids: ['node-1'], status: 'online', response_time_ms: null,
|
||||
}),
|
||||
})
|
||||
expect(mockSetNodeStatus).toHaveBeenCalledWith(
|
||||
'node-1',
|
||||
@@ -173,7 +179,9 @@ describe('useStatusPolling', () => {
|
||||
const ws = MockWebSocket.instances[0]
|
||||
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
||||
data: JSON.stringify({
|
||||
type: 'service_status', device_id: 'dev-9', node_ids: ['node-9'], services,
|
||||
}),
|
||||
})
|
||||
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
||||
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||
|
||||
@@ -11,14 +11,54 @@ import type { NodeData } from '@/types'
|
||||
|
||||
type NodeDrag = OnNodeDrag<Node<NodeData>>
|
||||
|
||||
function nodeBox(n: Node<NodeData>): Box | null {
|
||||
// Skip parented nodes — alignment between absolute and parent-relative
|
||||
// coordinates is misleading. Top-level nodes only for v1.
|
||||
if (n.parentId) return null
|
||||
// Absolute canvas position of a node: React Flow stores a parented node's
|
||||
// position relative to its parent, so we walk the parentId chain and sum.
|
||||
// `seen` guards against a corrupted cycle in the hierarchy.
|
||||
function absolutePosition(
|
||||
n: Node<NodeData>,
|
||||
byId: Map<string, Node<NodeData>>,
|
||||
): { x: number; y: number } {
|
||||
let x = n.position.x
|
||||
let y = n.position.y
|
||||
const seen = new Set<string>([n.id])
|
||||
let parentId = n.parentId
|
||||
while (parentId && !seen.has(parentId)) {
|
||||
seen.add(parentId)
|
||||
const parent = byId.get(parentId)
|
||||
if (!parent) break
|
||||
x += parent.position.x
|
||||
y += parent.position.y
|
||||
parentId = parent.parentId
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// Boxes are expressed in absolute canvas coordinates so a node inside a group
|
||||
// or a container_mode node aligns against siblings *and* outside nodes.
|
||||
function nodeBox(n: Node<NodeData>, byId: Map<string, Node<NodeData>>): Box | null {
|
||||
const width = n.measured?.width ?? n.width ?? null
|
||||
const height = n.measured?.height ?? n.height ?? null
|
||||
if (width == null || height == null) return null
|
||||
return { id: n.id, x: n.position.x, y: n.position.y, width, height }
|
||||
const { x, y } = absolutePosition(n, byId)
|
||||
return { id: n.id, x, y, width, height }
|
||||
}
|
||||
|
||||
// True when any ancestor of `n` is itself being dragged — such a node follows
|
||||
// its parent, so it must not be snapped again (double-shift) nor act as an
|
||||
// alignment candidate (it moves with the drag).
|
||||
function hasDraggedAncestor(
|
||||
n: Node<NodeData>,
|
||||
byId: Map<string, Node<NodeData>>,
|
||||
draggedIds: Set<string>,
|
||||
): boolean {
|
||||
const seen = new Set<string>([n.id])
|
||||
let parentId = n.parentId
|
||||
while (parentId && !seen.has(parentId)) {
|
||||
if (draggedIds.has(parentId)) return true
|
||||
seen.add(parentId)
|
||||
parentId = byId.get(parentId)?.parentId
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,27 +114,35 @@ export function useAlignmentGuides() {
|
||||
return
|
||||
}
|
||||
const all = getNodes()
|
||||
const byId = new Map(all.map((n) => [n.id, n]))
|
||||
const draggedIds = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id))
|
||||
// Restrict the snap to top-level dragged nodes. nodeBox returns null for
|
||||
// parented nodes; if we kept their ids in pendingSnap, onNodeDragStop
|
||||
// would shift their parent-relative position by the same delta the parent
|
||||
// already moves by, double-snapping the child. Children follow the parent
|
||||
// automatically; no extra shift needed.
|
||||
// A dragged node whose parent is also dragged already moves with that
|
||||
// parent; snapping it too would shift its parent-relative position by the
|
||||
// same delta twice. Its box still joins the union bbox (it is part of what
|
||||
// the user sees moving), it just never lands in pendingSnap.ids.
|
||||
const draggedBoxEntries = all
|
||||
.filter((n) => draggedIds.has(n.id))
|
||||
.map((n) => ({ id: n.id, box: nodeBox(n) }))
|
||||
.filter((e): e is { id: string; box: Box } => e.box !== null)
|
||||
.map((n) => ({
|
||||
id: n.id,
|
||||
box: nodeBox(n, byId),
|
||||
follows: hasDraggedAncestor(n, byId, draggedIds),
|
||||
}))
|
||||
.filter((e): e is { id: string; box: Box; follows: boolean } => e.box !== null)
|
||||
if (draggedBoxEntries.length === 0) {
|
||||
clearState()
|
||||
return
|
||||
}
|
||||
const snapIds = new Set(draggedBoxEntries.map((e) => e.id))
|
||||
const snapIds = new Set(draggedBoxEntries.filter((e) => !e.follows).map((e) => e.id))
|
||||
if (snapIds.size === 0) {
|
||||
clearState()
|
||||
return
|
||||
}
|
||||
const boxes = draggedBoxEntries.map((e) => e.box)
|
||||
const dragged = boxes.length === 1 ? boxes[0] : unionBox(boxes)
|
||||
if (!dragged) return
|
||||
const candidates = all
|
||||
.filter((n) => !draggedIds.has(n.id))
|
||||
.map(nodeBox)
|
||||
.filter((n) => !draggedIds.has(n.id) && !hasDraggedAncestor(n, byId, draggedIds))
|
||||
.map((n) => nodeBox(n, byId))
|
||||
.filter((b): b is Box => b !== null)
|
||||
const result = computeSnap(dragged, candidates, settings.threshold)
|
||||
setGuides(result.guides)
|
||||
|
||||
@@ -6,12 +6,19 @@ import type { ServiceStatus } from '@/types'
|
||||
interface ServiceStatusEntry {
|
||||
port?: number
|
||||
protocol?: string
|
||||
/** Per-service host override, part of the overlay key — several vhosts can
|
||||
* share one port on one node. Null when the service has none. */
|
||||
host?: string | null
|
||||
status: ServiceStatus
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
type?: string
|
||||
node_id?: string
|
||||
/** The device that was checked. A device is checked once, however many
|
||||
* canvases draw it. */
|
||||
device_id?: string
|
||||
/** The nodes drawing that device, as the backend knew them at check time. */
|
||||
node_ids?: string[]
|
||||
status?: 'online' | 'offline' | 'pending' | 'unknown'
|
||||
checked_at?: string
|
||||
response_time_ms?: number | null
|
||||
@@ -22,6 +29,25 @@ interface StatusMessage {
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
/**
|
||||
* The nodes a device-scoped message applies to.
|
||||
*
|
||||
* The backend sends the node ids it knew at check time; a node placed since
|
||||
* then is resolved from the store by `device_id`, so a freshly approved device
|
||||
* lights up on the first cycle rather than the second.
|
||||
*/
|
||||
function targets(msg: StatusMessage): string[] {
|
||||
const known = msg.node_ids ?? []
|
||||
if (!msg.device_id) return known
|
||||
// `getState` is absent when the store is mocked; the message's own list is
|
||||
// then the whole answer.
|
||||
const nodes = useCanvasStore.getState?.()?.nodes ?? []
|
||||
const fromStore = nodes
|
||||
.filter((n) => n.data.device_id === msg.device_id)
|
||||
.map((n) => n.id)
|
||||
return [...new Set([...known, ...fromStore])]
|
||||
}
|
||||
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||
@@ -51,16 +77,19 @@ export function useStatusPolling() {
|
||||
const msg: StatusMessage = JSON.parse(event.data)
|
||||
if (msg.type === 'scan_device_found') {
|
||||
notifyScanDeviceFound()
|
||||
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
||||
setServiceStatuses(msg.node_id, msg.services)
|
||||
} else if (msg.node_id && msg.status) {
|
||||
} else if (msg.type === 'service_status' && msg.services) {
|
||||
for (const nodeId of targets(msg)) setServiceStatuses(nodeId, msg.services)
|
||||
} else if (msg.status) {
|
||||
// Live status is monitoring data, not a user edit — must not dirty the
|
||||
// canvas (otherwise autosave rewrites an untouched canvas every cycle).
|
||||
setNodeStatus(msg.node_id, {
|
||||
status: msg.status,
|
||||
response_time_ms: msg.response_time_ms ?? undefined,
|
||||
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
|
||||
})
|
||||
// One check lights up every node drawing that device.
|
||||
for (const nodeId of targets(msg)) {
|
||||
setNodeStatus(nodeId, {
|
||||
status: msg.status,
|
||||
response_time_ms: msg.response_time_ms ?? undefined,
|
||||
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
|
||||
@@ -161,6 +161,19 @@ describe('CableLayer selection', () => {
|
||||
expect(lines).toEqual(['Uplink', 'Length: 2 m'])
|
||||
})
|
||||
|
||||
it('prints a value-less property as a bare label', () => {
|
||||
const store = useRackStore.getState()
|
||||
store.setCableVisibility('always')
|
||||
store.updateCable(store.cables[0].id, {
|
||||
labelVisible: false,
|
||||
properties: [{ key: 'Spare', value: '', icon: null, visible: true }],
|
||||
})
|
||||
|
||||
const { getAllByTestId } = render(<CableLayer />)
|
||||
const lines = Array.from(getAllByTestId('cable-annotation')[0].querySelectorAll('text')).map((t) => t.textContent)
|
||||
expect(lines).toEqual(['Spare'])
|
||||
})
|
||||
|
||||
it('draws the selected cable with a highlight halo', () => {
|
||||
useRackStore.getState().toggleCableMode()
|
||||
const { container, rerender } = render(<CableLayer />)
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('RackCablePanel', () => {
|
||||
render(<RackCablePanel />)
|
||||
|
||||
fireEvent.click(screen.getByText('+ Length'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Value (e.g. 2 m)'), { target: { value: '2 m' } })
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: '2 m' } })
|
||||
// Two buttons read "Add": the section's, then the form's confirm.
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
|
||||
@@ -47,7 +47,8 @@ function annotationLines(cable: Cable): string[] {
|
||||
const lines: string[] = []
|
||||
if (cable.labelVisible && cable.label?.trim()) lines.push(cable.label.trim())
|
||||
for (const prop of cable.properties ?? []) {
|
||||
if (prop.visible) lines.push(`${prop.key}: ${prop.value}`)
|
||||
// The value is optional — a property may print as a bare label.
|
||||
if (prop.visible) lines.push(prop.value?.trim() ? `${prop.key}: ${prop.value}` : prop.key)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function RackCablePanel() {
|
||||
onChange={(next) => updateCable(cable.id, { properties: next })}
|
||||
visibleLabel="Show on canvas"
|
||||
keyPlaceholder="Label (e.g. Length)"
|
||||
valuePlaceholder="Value (e.g. 2 m)"
|
||||
valuePlaceholder="Value — optional (e.g. 2 m)"
|
||||
suggestions={CABLE_PROPERTY_SUGGESTIONS}
|
||||
emptyHint="No properties — add a length, a VLAN, whatever this run needs."
|
||||
/>
|
||||
|
||||
@@ -83,6 +83,47 @@ describe('canvasStore — containers & nesting', () => {
|
||||
expect(node?.position.y).toBe(80)
|
||||
})
|
||||
|
||||
it('updateNode setting parent_id on a group nests the node visually', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group' }), position: { x: 100, y: 100 } }
|
||||
const child = { ...makeNode('s1', { type: 'server' }), position: { x: 160, y: 180 } }
|
||||
useCanvasStore.getState().addNode(group)
|
||||
useCanvasStore.getState().addNode(child)
|
||||
useCanvasStore.getState().updateNode('s1', { parent_id: 'g1' })
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 's1')
|
||||
expect(node?.parentId).toBe('g1')
|
||||
expect(node?.extent).toBe('parent')
|
||||
expect(node?.position).toEqual({ x: 60, y: 80 })
|
||||
})
|
||||
|
||||
it('updateNode re-sending an unchanged group parent_id keeps the node in place', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group' }), position: { x: 100, y: 100 } }
|
||||
const child = {
|
||||
...makeNode('s1', { type: 'server', parent_id: 'g1' }),
|
||||
parentId: 'g1',
|
||||
extent: 'parent' as const,
|
||||
position: { x: 20, y: 30 },
|
||||
}
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
// What the node modal submits on an unrelated edit: label plus the parent it
|
||||
// already had. It must not detach or re-offset the node (#332 follow-up).
|
||||
useCanvasStore.getState().updateNode('s1', { label: 'renamed', parent_id: 'g1' })
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 's1')
|
||||
expect(node?.data.parent_id).toBe('g1')
|
||||
expect(node?.parentId).toBe('g1')
|
||||
expect(node?.extent).toBe('parent')
|
||||
expect(node?.position).toEqual({ x: 20, y: 30 })
|
||||
})
|
||||
|
||||
it('addNode keeps a node nested when its parent is a group', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group' }), position: { x: 100, y: 100 } }
|
||||
useCanvasStore.setState({ nodes: [group] })
|
||||
useCanvasStore.getState().addNode({ ...makeNode('s1', { type: 'server', parent_id: 'g1' }), position: { x: 160, y: 180 } })
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 's1')
|
||||
expect(node?.parentId).toBe('g1')
|
||||
expect(node?.extent).toBe('parent')
|
||||
expect(node?.position).toEqual({ x: 60, y: 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 } }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The device-fact baseline: what the canvas was given for each node, so a save
|
||||
* can report its own edits instead of pushing a stale snapshot over an edit made
|
||||
* meanwhile in the Device Inventory.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { changedFactFields } from '@/utils/deviceFacts'
|
||||
import { makeNode, makeNodeData } from '@/test/factories'
|
||||
|
||||
function resetStore() {
|
||||
useCanvasStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
editSeq: 0,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
past: [],
|
||||
future: [],
|
||||
factsBaseline: {},
|
||||
})
|
||||
}
|
||||
|
||||
const loadedNode = (id = 'n1', deviceId = 'd-1') =>
|
||||
makeNode(id, makeNodeData({ device_id: deviceId, label: 'NAS', ip: '10.0.0.5', notes: 'in the garage' }))
|
||||
|
||||
describe('canvasStore — device fact baseline', () => {
|
||||
beforeEach(resetStore)
|
||||
|
||||
it('loadCanvas records what the server sent', () => {
|
||||
const node = loadedNode()
|
||||
useCanvasStore.getState().loadCanvas([node], [])
|
||||
const { factsBaseline } = useCanvasStore.getState()
|
||||
expect(changedFactFields(node.data, factsBaseline.n1)).toEqual([])
|
||||
})
|
||||
|
||||
it('an edited node reports only what it edited', () => {
|
||||
useCanvasStore.getState().loadCanvas([loadedNode()], [])
|
||||
useCanvasStore.getState().updateNode('n1', { notes: 'moved to the loft' })
|
||||
const { nodes, factsBaseline } = useCanvasStore.getState()
|
||||
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual(['notes'])
|
||||
})
|
||||
|
||||
it('markSaved rebases, so the next save claims nothing', () => {
|
||||
useCanvasStore.getState().loadCanvas([loadedNode()], [])
|
||||
useCanvasStore.getState().updateNode('n1', { notes: 'moved to the loft' })
|
||||
useCanvasStore.getState().markSaved()
|
||||
const { nodes, factsBaseline } = useCanvasStore.getState()
|
||||
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvasStore — applyDeviceFacts', () => {
|
||||
beforeEach(resetStore)
|
||||
|
||||
it('spreads an inventory edit onto every node drawing that device', () => {
|
||||
useCanvasStore.getState().loadCanvas([loadedNode('n1'), loadedNode('n2')], [])
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
|
||||
expect(useCanvasStore.getState().nodes.map((n) => n.data.notes)).toEqual([
|
||||
'moved to the loft',
|
||||
'moved to the loft',
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a node drawing another device alone', () => {
|
||||
useCanvasStore.getState().loadCanvas([loadedNode('n1', 'd-1'), loadedNode('n2', 'd-2')], [])
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
|
||||
expect(useCanvasStore.getState().nodes[1].data.notes).toBe('in the garage')
|
||||
})
|
||||
|
||||
it('rebases what it applied, so the canvas does not claim the row edit as its own', () => {
|
||||
useCanvasStore.getState().loadCanvas([loadedNode()], [])
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
|
||||
const { nodes, factsBaseline } = useCanvasStore.getState()
|
||||
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual([])
|
||||
})
|
||||
|
||||
it('is not a canvas edit — the row already holds it', () => {
|
||||
useCanvasStore.getState().loadCanvas([loadedNode()], [])
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
expect(useCanvasStore.getState().editSeq).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a pending canvas edit when the same device changes elsewhere', () => {
|
||||
useCanvasStore.getState().loadCanvas([loadedNode()], [])
|
||||
useCanvasStore.getState().updateNode('n1', { label: 'Big NAS' })
|
||||
// The row carries its own (older) label alongside the edited note.
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { label: 'NAS', notes: 'moved to the loft' })
|
||||
const { nodes, factsBaseline } = useCanvasStore.getState()
|
||||
// Unsaved work in progress is not overwritten by the row…
|
||||
expect(nodes[0].data.label).toBe('Big NAS')
|
||||
expect(nodes[0].data.notes).toBe('moved to the loft')
|
||||
// …and is still this canvas' to save. The note is not.
|
||||
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual(['label'])
|
||||
})
|
||||
})
|
||||
@@ -34,8 +34,25 @@ describe('canvasStore — nodes', () => {
|
||||
{ port: 443, protocol: 'tcp', status: 'online' },
|
||||
])
|
||||
const { serviceStatuses } = useCanvasStore.getState()
|
||||
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
|
||||
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
|
||||
expect(serviceStatuses['node-1:80/tcp@']).toBe('offline')
|
||||
expect(serviceStatuses['node-1:443/tcp@']).toBe('online')
|
||||
})
|
||||
|
||||
it('setServiceStatuses keys vhosts sharing a port apart by host', () => {
|
||||
const { setServiceStatuses } = useCanvasStore.getState()
|
||||
setServiceStatuses('node-1', [
|
||||
{ port: 443, protocol: 'tcp', host: 'blog.example.com', status: 'online' },
|
||||
{ port: 443, protocol: 'tcp', host: 'shop.example.com', status: 'offline' },
|
||||
])
|
||||
const { serviceStatuses } = useCanvasStore.getState()
|
||||
expect(serviceStatuses['node-1:443/tcp@blog.example.com']).toBe('online')
|
||||
expect(serviceStatuses['node-1:443/tcp@shop.example.com']).toBe('offline')
|
||||
})
|
||||
|
||||
it('setServiceStatuses treats a null host from the wire as no override', () => {
|
||||
const { setServiceStatuses } = useCanvasStore.getState()
|
||||
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', host: null, status: 'online' }])
|
||||
expect(useCanvasStore.getState().serviceStatuses['node-1:80/tcp@']).toBe('online')
|
||||
})
|
||||
|
||||
it('setServiceStatuses merges without dropping other nodes', () => {
|
||||
@@ -43,8 +60,8 @@ describe('canvasStore — nodes', () => {
|
||||
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
|
||||
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
|
||||
const { serviceStatuses } = useCanvasStore.getState()
|
||||
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
|
||||
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
|
||||
expect(serviceStatuses['node-1:80/tcp@']).toBe('online')
|
||||
expect(serviceStatuses['node-2:22/tcp@']).toBe('offline')
|
||||
})
|
||||
|
||||
it('does not mark canvas unsaved on a service status update', () => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { makeNode } from '@/test/factories'
|
||||
|
||||
function resetStore() {
|
||||
useCanvasStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
editingTextId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
serviceStatuses: {},
|
||||
floorMap: null,
|
||||
})
|
||||
}
|
||||
|
||||
const zone = (id: string, x = 100, y = 100) => ({
|
||||
...makeNode(id, { type: 'groupRect' }),
|
||||
type: 'groupRect',
|
||||
position: { x, y },
|
||||
width: 400,
|
||||
height: 300,
|
||||
})
|
||||
|
||||
describe('canvasStore — zone (groupRect) parenting', () => {
|
||||
beforeEach(resetStore)
|
||||
|
||||
it('addToZone parents the node and rebases its position on the zone', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
|
||||
})
|
||||
useCanvasStore.getState().addToZone('z1', 'n1')
|
||||
|
||||
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||
expect(child.parentId).toBe('z1')
|
||||
expect(child.data.parent_id).toBe('z1')
|
||||
expect(child.position).toEqual({ x: 60, y: 120 })
|
||||
})
|
||||
|
||||
it('addToZone does not clamp the child with extent, so it can be dragged out', () => {
|
||||
useCanvasStore.setState({ nodes: [zone('z1'), makeNode('n1')] })
|
||||
useCanvasStore.getState().addToZone('z1', 'n1')
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.extent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('addToZone orders the zone before its child (React Flow requirement)', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1'), zone('z1')] })
|
||||
useCanvasStore.getState().addToZone('z1', 'n1')
|
||||
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
|
||||
expect(ids.indexOf('z1')).toBeLessThan(ids.indexOf('n1'))
|
||||
})
|
||||
|
||||
it('addToZone is a no-op when the target is not a zone', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('g1', { type: 'group' }), makeNode('n1')] })
|
||||
useCanvasStore.getState().addToZone('g1', 'n1')
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('addToZone is a no-op when the node is already in that zone', () => {
|
||||
useCanvasStore.setState({ nodes: [zone('z1'), makeNode('n1')] })
|
||||
useCanvasStore.getState().addToZone('z1', 'n1')
|
||||
const first = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||
useCanvasStore.getState().addToZone('z1', 'n1')
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')).toEqual(first)
|
||||
})
|
||||
|
||||
it('removeFromGroup releases a zone child back to absolute coords', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
|
||||
})
|
||||
useCanvasStore.getState().addToZone('z1', 'n1')
|
||||
useCanvasStore.getState().removeFromGroup('z1', 'n1')
|
||||
|
||||
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||
expect(child.parentId).toBeUndefined()
|
||||
expect(child.data.parent_id).toBeUndefined()
|
||||
expect(child.position).toEqual({ x: 160, y: 220 })
|
||||
})
|
||||
|
||||
it('deleting a zone releases its children instead of deleting them', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
|
||||
})
|
||||
useCanvasStore.getState().addToZone('z1', 'n1')
|
||||
useCanvasStore.getState().deleteNode('z1')
|
||||
|
||||
const nodes = useCanvasStore.getState().nodes
|
||||
expect(nodes.map((n) => n.id)).toEqual(['n1'])
|
||||
expect(nodes[0].parentId).toBeUndefined()
|
||||
expect(nodes[0].position).toEqual({ x: 160, y: 220 })
|
||||
})
|
||||
|
||||
it('deleting a container still cascades to its children', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
makeNode('px1', { type: 'proxmox', container_mode: true }),
|
||||
{ ...makeNode('vm1'), parentId: 'px1' },
|
||||
],
|
||||
})
|
||||
useCanvasStore.getState().deleteNode('px1')
|
||||
expect(useCanvasStore.getState().nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('addNode nests a node under a zone without clamping it', () => {
|
||||
useCanvasStore.getState().addNode(zone('z1'))
|
||||
useCanvasStore.getState().addNode({
|
||||
...makeNode('n1', { parent_id: 'z1' }),
|
||||
position: { x: 160, y: 220 },
|
||||
})
|
||||
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||
expect(child.parentId).toBe('z1')
|
||||
expect(child.extent).toBeUndefined()
|
||||
expect(child.position).toEqual({ x: 60, y: 120 })
|
||||
})
|
||||
|
||||
it('updateNode can attach a node to a zone and detach it again', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
|
||||
})
|
||||
useCanvasStore.getState().updateNode('n1', { parent_id: 'z1' })
|
||||
const attached = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||
expect(attached.parentId).toBe('z1')
|
||||
expect(attached.extent).toBeUndefined()
|
||||
|
||||
useCanvasStore.getState().updateNode('n1', { parent_id: undefined })
|
||||
const detached = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
|
||||
expect(detached.parentId).toBeUndefined()
|
||||
expect(detached.position).toEqual({ x: 160, y: 220 })
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handl
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||
import { CONTAINER_MODE_TYPES } from '@/utils/virtualEdgeParent'
|
||||
import { changedFactFields, factsBaselineOf, factsBaselines, type FactsBaseline } from '@/utils/deviceFacts'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
@@ -101,9 +102,11 @@ function translateWaypointsForMovedNodes(
|
||||
})
|
||||
}
|
||||
|
||||
/** Key for the live per-service status overlay. */
|
||||
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
|
||||
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
|
||||
/** Key for the live per-service status overlay.
|
||||
* `host` is part of the key because several vhosts can share one port on one
|
||||
* node — without it they'd all read the same status. */
|
||||
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string, host?: string | null): string =>
|
||||
`${nodeId}:${port ?? ''}/${protocol ?? ''}@${host?.trim() ?? ''}`
|
||||
|
||||
interface CanvasState {
|
||||
nodes: Node<NodeData>[]
|
||||
@@ -120,6 +123,19 @@ interface CanvasState {
|
||||
selectedNodeId: string | null
|
||||
selectedNodeIds: string[]
|
||||
scanEventTs: number
|
||||
/**
|
||||
* The device facts as this canvas received them, per node id. A save diffs
|
||||
* against it to tell the backend what *this* canvas edited, so a save made for
|
||||
* nothing but a moved node cannot push a stale snapshot over an edit made
|
||||
* meanwhile in the Device Inventory. Refreshed on load and on save.
|
||||
*/
|
||||
factsBaseline: Record<string, FactsBaseline>
|
||||
/**
|
||||
* Apply an inventory row's facts to every node drawing that device, and rebase
|
||||
* them. Not an edit — the row is already persisted — so it leaves
|
||||
* hasUnsavedChanges alone.
|
||||
*/
|
||||
applyDeviceFacts: (deviceId: string, facts: Partial<NodeData>) => void
|
||||
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
|
||||
serviceStatuses: Record<string, ServiceStatus>
|
||||
|
||||
@@ -175,6 +191,7 @@ interface CanvasState {
|
||||
ungroup: (groupId: string) => void
|
||||
addToGroup: (groupId: string, childId: string) => void
|
||||
addToContainer: (containerId: string, childId: string) => void
|
||||
addToZone: (zoneId: string, childId: string) => void
|
||||
removeFromGroup: (groupId: string, childId: string) => void
|
||||
markSaved: () => void
|
||||
markUnsaved: () => void
|
||||
@@ -186,7 +203,7 @@ interface CanvasState {
|
||||
fitViewPending: boolean
|
||||
clearFitViewPending: () => void
|
||||
notifyScanDeviceFound: () => void
|
||||
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
|
||||
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; host?: string | null; status: ServiceStatus }[]) => void
|
||||
hideIp: boolean
|
||||
toggleHideIp: () => void
|
||||
setHideIp: (value: boolean) => void
|
||||
@@ -228,6 +245,7 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
editingTextId: null,
|
||||
hideIp: readHideIp(),
|
||||
scanEventTs: 0,
|
||||
factsBaseline: {},
|
||||
serviceStatuses: {},
|
||||
floorMap: null,
|
||||
floorMapEditNonce: 0,
|
||||
@@ -431,12 +449,16 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
addNode: (node) =>
|
||||
set((state) => {
|
||||
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
|
||||
const shouldNestInParent = !!(parent?.data.container_mode)
|
||||
// A visual group — and a groupRect zone — nests its children just like a
|
||||
// container-mode host.
|
||||
const shouldNestInParent = !!(parent?.data.container_mode) || parent?.data.type === 'group' || parent?.data.type === 'groupRect'
|
||||
// Zones keep their children free to be dragged back out (see addToZone).
|
||||
const nestExtent = parent?.data.type === 'groupRect' ? undefined : ('parent' as const)
|
||||
const enriched = node.data.parent_id && shouldNestInParent
|
||||
? {
|
||||
...node,
|
||||
parentId: node.data.parent_id,
|
||||
extent: 'parent' as const,
|
||||
extent: nestExtent,
|
||||
position: {
|
||||
x: Math.max(10, node.position.x - parent.position.x),
|
||||
y: Math.max(10, node.position.y - parent.position.y),
|
||||
@@ -485,10 +507,10 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
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
|
||||
if (parent?.data.container_mode || parent?.data.type === 'group' || parent?.data.type === 'groupRect') {
|
||||
// Attaching to a container-mode host, a visual group or a zone.
|
||||
updated.parentId = newParentId
|
||||
updated.extent = 'parent' as const
|
||||
updated.extent = parent.data.type === 'groupRect' ? undefined : ('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),
|
||||
@@ -545,13 +567,34 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
deleteNode: (id) =>
|
||||
set((state) => {
|
||||
const idsToRemove = new Set<string>()
|
||||
// Deleting a zone deletes the zone, never what it happens to contain:
|
||||
// its children are released back to the canvas in absolute coords.
|
||||
const released: Node<NodeData>[] = []
|
||||
const collect = (nodeId: string) => {
|
||||
idsToRemove.add(nodeId)
|
||||
state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id))
|
||||
const node = state.nodes.find((n) => n.id === nodeId)
|
||||
const children = state.nodes.filter((n) => n.parentId === nodeId)
|
||||
if (node?.data.type === 'groupRect') {
|
||||
children.forEach((c) => released.push({
|
||||
...c,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: {
|
||||
x: node.position.x + c.position.x,
|
||||
y: node.position.y + c.position.y,
|
||||
},
|
||||
data: { ...c.data, parent_id: undefined },
|
||||
}))
|
||||
return
|
||||
}
|
||||
children.forEach((n) => collect(n.id))
|
||||
}
|
||||
collect(id)
|
||||
const releasedById = new Map(released.map((n) => [n.id, n]))
|
||||
return {
|
||||
nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)),
|
||||
nodes: state.nodes
|
||||
.filter((n) => !idsToRemove.has(n.id))
|
||||
.map((n) => releasedById.get(n.id) ?? n),
|
||||
edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)),
|
||||
selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId,
|
||||
hasUnsavedChanges: true,
|
||||
@@ -873,6 +916,53 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
}
|
||||
}),
|
||||
|
||||
// Nest an existing top-level node inside a groupRect zone. Mirrors
|
||||
// addToGroup, with one deliberate difference: NO `extent: 'parent'`. A zone
|
||||
// is a loose visual area, so a child must stay draggable out of it — the
|
||||
// canvas detaches it on drop (see CanvasContainer). Parenting is what makes
|
||||
// moving the zone move its contents.
|
||||
addToZone: (zoneId, childId) =>
|
||||
set((state) => {
|
||||
const zone = state.nodes.find((n) => n.id === zoneId)
|
||||
const child = state.nodes.find((n) => n.id === childId)
|
||||
if (!zone || !child || zone.data.type !== 'groupRect') return state
|
||||
if (child.id === zoneId || child.parentId === zoneId) return state
|
||||
// A zone cannot become a child of a node it already contains.
|
||||
if (zone.parentId === childId) return state
|
||||
|
||||
const updatedNodes = state.nodes.map((n) => {
|
||||
if (n.id !== childId) return n
|
||||
return {
|
||||
...n,
|
||||
parentId: zoneId,
|
||||
// Absolute → zone-relative.
|
||||
position: {
|
||||
x: n.position.x - zone.position.x,
|
||||
y: n.position.y - zone.position.y,
|
||||
},
|
||||
selected: false,
|
||||
data: { ...n.data, parent_id: zoneId },
|
||||
}
|
||||
})
|
||||
|
||||
// React Flow requires the parent to precede its children in the array.
|
||||
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||
const zoneIdx = others.findIndex((n) => n.id === zoneId)
|
||||
const nodes = [
|
||||
...others.slice(0, zoneIdx + 1),
|
||||
movedChild,
|
||||
...others.slice(zoneIdx + 1),
|
||||
]
|
||||
|
||||
return {
|
||||
nodes,
|
||||
hasUnsavedChanges: true,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
}
|
||||
}),
|
||||
|
||||
// Release a single child from a group back to the canvas. Group stays.
|
||||
removeFromGroup: (groupId, childId) =>
|
||||
set((state) => {
|
||||
@@ -902,7 +992,40 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
}
|
||||
}),
|
||||
|
||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||
// The save just became the server's truth, so it is the new baseline: the next
|
||||
// save reports only what is edited from here on.
|
||||
markSaved: () => set((state) => ({ hasUnsavedChanges: false, factsBaseline: factsBaselines(state.nodes) })),
|
||||
|
||||
applyDeviceFacts: (deviceId, facts) =>
|
||||
set((state) => {
|
||||
let changed = false
|
||||
const factsBaseline = { ...state.factsBaseline }
|
||||
const nodes = state.nodes.map((n) => {
|
||||
if (n.data.device_id !== deviceId) return n
|
||||
const previous = state.factsBaseline[n.id]
|
||||
// A fact this canvas has already edited but not saved is the user's
|
||||
// work in progress — the row does not get to overwrite it on screen.
|
||||
const pending = new Set<string>(changedFactFields(n.data, previous))
|
||||
const applied = Object.fromEntries(
|
||||
Object.entries(facts).filter(([field]) => !pending.has(field)),
|
||||
)
|
||||
if (Object.keys(applied).length === 0) return n
|
||||
changed = true
|
||||
const data = { ...n.data, ...applied }
|
||||
// Rebase only what was applied: an untouched pending edit stays reported
|
||||
// as this canvas' change so the next save still writes it.
|
||||
const rebased = factsBaselineOf(data)
|
||||
for (const field of pending) {
|
||||
if (previous?.[field] !== undefined) rebased[field] = previous[field]
|
||||
}
|
||||
factsBaseline[n.id] = rebased
|
||||
return { ...n, data }
|
||||
})
|
||||
// No hasUnsavedChanges: the row already holds this, the canvas is catching
|
||||
// up. Rebasing alongside is what keeps the next save from claiming the
|
||||
// inventory's own edit as a canvas edit.
|
||||
return changed ? { nodes, factsBaseline } : {}
|
||||
}),
|
||||
|
||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||
|
||||
@@ -913,7 +1036,7 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
// Live overlay only — never touches node data, so it stays out of saves.
|
||||
const next = { ...state.serviceStatuses }
|
||||
for (const s of statuses) {
|
||||
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
|
||||
next[serviceStatusKey(nodeId, s.port, s.protocol, s.host)] = s.status
|
||||
}
|
||||
return { serviceStatuses: next }
|
||||
}),
|
||||
@@ -945,7 +1068,17 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
const children = nodes.filter((n) => !!n.parentId)
|
||||
// NOTE: clipboard is intentionally preserved here so nodes copied in one
|
||||
// design can be pasted after switching to another design.
|
||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
|
||||
set({
|
||||
nodes: [...parents, ...children],
|
||||
edges,
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
fitViewPending: true,
|
||||
// What the server just gave us: the reference a save diffs against.
|
||||
factsBaseline: factsBaselines(nodes),
|
||||
})
|
||||
},
|
||||
|
||||
applyLayout: (nodes, edges) =>
|
||||
|
||||
@@ -65,6 +65,6 @@ export function makeUseCanvasStore<S extends Record<string, unknown>>(state: S)
|
||||
}
|
||||
|
||||
/** Common `serviceStatusKey` helper mirrored from the real store. */
|
||||
export function serviceStatusKey(nodeId: string, port?: number, protocol?: string) {
|
||||
return `${nodeId}:${port ?? ''}/${protocol ?? ''}`
|
||||
export function serviceStatusKey(nodeId: string, port?: number, protocol?: string, host?: string) {
|
||||
return `${nodeId}:${port ?? ''}/${protocol ?? ''}@${host?.trim() ?? ''}`
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type NodeType =
|
||||
| 'vm'
|
||||
| 'lxc'
|
||||
| 'nas'
|
||||
| 'kvm'
|
||||
| 'iot'
|
||||
| 'ap'
|
||||
| 'camera'
|
||||
@@ -92,6 +93,10 @@ export interface ServiceInfo {
|
||||
path?: string
|
||||
icon?: string
|
||||
category?: string
|
||||
/** Overrides the node host when building the service URL — one node can serve
|
||||
* several domains. Same accepted shapes as a node `ip`/`hostname`
|
||||
* (`host`, `host:port`, `https://host/…`). */
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type ServiceStatus = 'online' | 'offline' | 'unknown'
|
||||
@@ -103,9 +108,79 @@ export interface NodeProperty {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A Device Inventory row, as `/scan/pending` returns it.
|
||||
*
|
||||
* The inventory row — not the canvas node — owns what a device *is*: its
|
||||
* addresses, services, properties, notes and hardware. A node describes how
|
||||
* that device is drawn on one canvas. Re-exported from
|
||||
* `components/modals/InventoryDeviceModal` for the call sites that predate this
|
||||
* home.
|
||||
*/
|
||||
export interface InventoryEntry {
|
||||
id: string
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
hostname: string | null
|
||||
os: string | null
|
||||
services: ServiceInfo[]
|
||||
suggested_type: string | null
|
||||
/** Inventory lifecycle: pending / approved / hidden. Not reachability. */
|
||||
status: string
|
||||
discovery_source?: string | null
|
||||
/**
|
||||
* All sources that have observed this device (e.g. ["arp", "proxmox"]). A
|
||||
* merged device shows under every matching filter. Falls back to
|
||||
* [discovery_source] when absent (older rows).
|
||||
*/
|
||||
discovery_sources?: string[]
|
||||
ieee_address?: string | null
|
||||
friendly_name?: string | null
|
||||
device_subtype?: string | null
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
properties?: NodeProperty[]
|
||||
discovered_at: string
|
||||
/** Curated facts, editable from the device detail modal. */
|
||||
label?: string | null
|
||||
type?: string | null
|
||||
notes?: string | null
|
||||
cpu_count?: number | null
|
||||
cpu_model?: string | null
|
||||
ram_gb?: number | null
|
||||
disk_gb?: number | null
|
||||
show_hardware?: boolean
|
||||
check_method?: CheckMethod | null
|
||||
check_target?: string | null
|
||||
/** Live reachability from the status checker — distinct from `status`. */
|
||||
status_live?: string
|
||||
last_seen?: string | null
|
||||
last_scan?: string | null
|
||||
response_time_ms?: number | null
|
||||
updated_at?: string | null
|
||||
/** How many canvases (designs) this device already appears on. Server-computed. */
|
||||
canvas_count?: number
|
||||
/**
|
||||
* Timestamps from the linked canvas node(s), correlated by ip/ieee_address.
|
||||
* Null/absent when the device is not on any canvas yet.
|
||||
*/
|
||||
node_created_at?: string | null
|
||||
node_last_scan?: string | null
|
||||
node_last_modified?: string | null
|
||||
node_last_seen?: string | null
|
||||
}
|
||||
|
||||
export interface NodeData extends Record<string, unknown> {
|
||||
label: string
|
||||
type: NodeType
|
||||
/**
|
||||
* The Device Inventory row this node draws. The row owns the device facts
|
||||
* below (ip, services, properties, notes…) — they arrive hydrated from it and
|
||||
* a save routes them back to it. Null/absent for canvas furniture
|
||||
* (group / groupRect / text), which describes nothing physical.
|
||||
*/
|
||||
device_id?: string | null
|
||||
hostname?: string
|
||||
ip?: string
|
||||
mac?: string
|
||||
@@ -213,6 +288,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
vm: 'Virtual Machine',
|
||||
lxc: 'LXC Container',
|
||||
nas: 'NAS',
|
||||
kvm: 'KVM Switch',
|
||||
iot: 'IoT Device',
|
||||
ap: 'Access Point',
|
||||
camera: 'Camera',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* A node carries the id of the Device Inventory row it draws. The link has to
|
||||
* survive a save, or the next one would mint a second row for the same device.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serializeNode, deserializeApiNode } from '@/utils/canvasSerializer'
|
||||
import { DEVICE_FACT_FIELDS, factsBaselineOf } from '@/utils/deviceFacts'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
function node(data: Partial<NodeData>): Node<NodeData> {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: data.type ?? 'server',
|
||||
position: { x: 10, y: 20 },
|
||||
data: { label: 'NAS', type: 'server', status: 'online', services: [], ...data },
|
||||
}
|
||||
}
|
||||
|
||||
describe('serializeNode — device link', () => {
|
||||
it('sends the device_id it was loaded with', () => {
|
||||
expect(serializeNode(node({ device_id: 'dev-1' })).device_id).toBe('dev-1')
|
||||
})
|
||||
|
||||
it('sends null when the node has no row yet', () => {
|
||||
expect(serializeNode(node({})).device_id).toBeNull()
|
||||
})
|
||||
|
||||
it('sends null for a zone — furniture describes nothing physical', () => {
|
||||
expect(serializeNode(node({ type: 'groupRect' })).device_id).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeNode — changed_facts', () => {
|
||||
it('claims nothing when the node still holds what it loaded', () => {
|
||||
const n = node({ device_id: 'dev-1', notes: 'in the garage' })
|
||||
expect(serializeNode(n, factsBaselineOf(n.data)).changed_facts).toEqual([])
|
||||
})
|
||||
|
||||
it('claims only the edited fact, so the rest of the row is left alone', () => {
|
||||
const loaded = node({ device_id: 'dev-1', notes: 'in the garage' })
|
||||
const edited = node({ device_id: 'dev-1', notes: 'moved to the loft' })
|
||||
expect(serializeNode(edited, factsBaselineOf(loaded.data)).changed_facts).toEqual(['notes'])
|
||||
})
|
||||
|
||||
it('claims every fact with no baseline — a node the server has never seen', () => {
|
||||
expect(serializeNode(node({ device_id: null })).changed_facts).toEqual([...DEVICE_FACT_FIELDS])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deserializeApiNode — device link', () => {
|
||||
it('hoists device_id onto node data', () => {
|
||||
const rf = deserializeApiNode(
|
||||
{
|
||||
id: 'n1',
|
||||
type: 'server',
|
||||
label: 'NAS',
|
||||
pos_x: 0,
|
||||
pos_y: 0,
|
||||
status: 'online',
|
||||
services: [],
|
||||
device_id: 'dev-1',
|
||||
},
|
||||
new Map(),
|
||||
)
|
||||
expect(rf.data.device_id).toBe('dev-1')
|
||||
})
|
||||
})
|
||||
@@ -366,6 +366,12 @@ describe('deserializeApiNode — regular node', () => {
|
||||
expect(result.extent).toBe('parent')
|
||||
})
|
||||
|
||||
it('restores a zone child parented but not extent-clamped', () => {
|
||||
const result = deserializeApiNode(makeApiNode({ parent_id: 'z1' }), emptyMap, new Set(['z1']))
|
||||
expect(result.parentId).toBe('z1')
|
||||
expect(result.extent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not set parentId when parent is not in container mode', () => {
|
||||
const map = new Map([['px1', false]])
|
||||
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getVisibleNodeIds,
|
||||
rewireEdgesForCollapse,
|
||||
getZoneSpatialChildren,
|
||||
getZoneChildren,
|
||||
computeCollapseInfo,
|
||||
} from '../collapseFilter'
|
||||
import type { EdgeData, NodeData } from '@/types'
|
||||
@@ -134,6 +135,31 @@ describe('getZoneSpatialChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getZoneChildren', () => {
|
||||
it('unions real parentId children with the ones merely sitting on the zone', () => {
|
||||
const zone = mkNode('zone', { position: { x: 0, y: 0 }, width: 400, height: 300 })
|
||||
const parented = mkNode('parented', { parentId: 'zone', type: 'server' })
|
||||
const overlapping = mkNode('overlapping', { position: { x: 100, y: 50 }, type: 'server' })
|
||||
const outside = mkNode('outside', { position: FAR, type: 'server' })
|
||||
const found = getZoneChildren(zone, [zone, parented, overlapping, outside])
|
||||
expect(found.sort()).toEqual(['overlapping', 'parented'])
|
||||
})
|
||||
|
||||
it('does not double-count a child that is both parented and overlapping', () => {
|
||||
const zone = mkNode('zone', { position: { x: 0, y: 0 }, width: 400, height: 300 })
|
||||
const child = mkNode('child', { parentId: 'zone', position: { x: 10, y: 10 }, type: 'server' })
|
||||
expect(getZoneChildren(zone, [zone, child])).toEqual(['child'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeCollapseInfo — a zone with real children', () => {
|
||||
it('hides parentId children when the zone collapses', () => {
|
||||
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
|
||||
const child = mkNode('child', { parentId: 'zone', type: 'server' })
|
||||
expect(getVisibleNodeIds([zone, child])).toEqual(new Set(['zone']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeCollapseInfo — spatial collapse via groupRect zones', () => {
|
||||
it('hides nodes spatially inside a collapsed zone and records hiddenBy', () => {
|
||||
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* A node carries a copy of its inventory row's facts, hydrated on load. Telling
|
||||
* "this canvas edited it" apart from "the row moved on since" needs the values
|
||||
* the canvas was given — that baseline is what these functions keep.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DEVICE_FACT_FIELDS,
|
||||
changedFactFields,
|
||||
deviceFactsToNodeData,
|
||||
factsBaselineOf,
|
||||
factsBaselines,
|
||||
} from '@/utils/deviceFacts'
|
||||
import type { InventoryEntry, NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
const data = (over: Partial<NodeData> = {}): NodeData => ({
|
||||
label: 'NAS',
|
||||
type: 'nas',
|
||||
status: 'online',
|
||||
services: [],
|
||||
ip: '10.0.0.5',
|
||||
notes: 'in the garage',
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('changedFactFields', () => {
|
||||
it('reports nothing when the node still holds what it was given', () => {
|
||||
const base = factsBaselineOf(data())
|
||||
expect(changedFactFields(data(), base)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports only the edited field', () => {
|
||||
const base = factsBaselineOf(data())
|
||||
expect(changedFactFields(data({ notes: 'moved to the loft' }), base)).toEqual(['notes'])
|
||||
})
|
||||
|
||||
it('ignores live status — the checker owns reachability, not a canvas', () => {
|
||||
const base = factsBaselineOf(data())
|
||||
expect(changedFactFields(data({ status: 'offline' }), base)).toEqual([])
|
||||
expect(DEVICE_FACT_FIELDS).not.toContain('status')
|
||||
})
|
||||
|
||||
it('compares lists by value, not identity', () => {
|
||||
const props = [{ key: 'Rack', value: 'A1', icon: null, visible: true }]
|
||||
const base = factsBaselineOf(data({ properties: props }))
|
||||
expect(changedFactFields(data({ properties: [...props] }), base)).toEqual([])
|
||||
expect(changedFactFields(data({ properties: [] }), base)).toEqual(['properties'])
|
||||
})
|
||||
|
||||
it('treats every fact as changed with no baseline — a new node has nothing to revert', () => {
|
||||
expect(changedFactFields(data(), undefined)).toEqual([...DEVICE_FACT_FIELDS])
|
||||
})
|
||||
})
|
||||
|
||||
describe('factsBaselines', () => {
|
||||
it('keys one baseline per node', () => {
|
||||
const nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: data() },
|
||||
{ id: 'n2', position: { x: 0, y: 0 }, data: data({ notes: 'rack B' }) },
|
||||
] as Node<NodeData>[]
|
||||
const baselines = factsBaselines(nodes)
|
||||
expect(Object.keys(baselines)).toEqual(['n1', 'n2'])
|
||||
expect(changedFactFields(nodes[1].data, baselines.n2)).toEqual([])
|
||||
expect(changedFactFields(nodes[1].data, baselines.n1)).toEqual(['notes'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deviceFactsToNodeData', () => {
|
||||
const row = (over: Partial<InventoryEntry> = {}): InventoryEntry => ({
|
||||
id: 'd-1',
|
||||
ip: '10.0.0.5',
|
||||
mac: null,
|
||||
hostname: 'nas.lan',
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: null,
|
||||
status: 'approved',
|
||||
discovered_at: '2026-01-01T00:00:00Z',
|
||||
...over,
|
||||
})
|
||||
|
||||
it('carries the curated facts a node draws', () => {
|
||||
const out = deviceFactsToNodeData(row({ label: 'Big NAS', type: 'nas', notes: 'loft' }))
|
||||
expect(out.label).toBe('Big NAS')
|
||||
expect(out.type).toBe('nas')
|
||||
expect(out.notes).toBe('loft')
|
||||
expect(out.ip).toBe('10.0.0.5')
|
||||
})
|
||||
|
||||
it('leaves the inventory lifecycle out — it is not node data', () => {
|
||||
expect(deviceFactsToNodeData(row()).status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back through the same name precedence the inventory shows', () => {
|
||||
expect(deviceFactsToNodeData(row({ friendly_name: 'Nas box' })).label).toBe('Nas box')
|
||||
expect(deviceFactsToNodeData(row()).label).toBe('nas.lan')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
MIN_BOTTOM_HANDLES,
|
||||
MAX_BOTTOM_HANDLES,
|
||||
MAX_HANDLES,
|
||||
MIN_HANDLES,
|
||||
sideHandleCount,
|
||||
bottomHandleId,
|
||||
bottomHandlePositions,
|
||||
clampBottomHandles,
|
||||
@@ -36,6 +38,7 @@ describe('clampBottomHandles', () => {
|
||||
it('clamps below MIN to MIN', () => {
|
||||
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(MIN_BOTTOM_HANDLES).toBe(0)
|
||||
})
|
||||
|
||||
it('supports at least 52 ports (issue #20 — Cisco 48+4 SFP)', () => {
|
||||
@@ -47,11 +50,11 @@ describe('clampBottomHandles', () => {
|
||||
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('returns MIN for non-finite or non-number', () => {
|
||||
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
|
||||
it('returns the side default for non-finite or non-number', () => {
|
||||
expect(clampBottomHandles(NaN)).toBe(1)
|
||||
expect(clampBottomHandles(Infinity)).toBe(1)
|
||||
expect(clampBottomHandles('4' as unknown)).toBe(1)
|
||||
expect(clampBottomHandles(undefined)).toBe(1)
|
||||
})
|
||||
|
||||
it('floors fractional values', () => {
|
||||
@@ -113,7 +116,8 @@ describe('bottomHandlePositions — uniform spacing for ≥5', () => {
|
||||
})
|
||||
|
||||
it('clamps out-of-range counts before computing', () => {
|
||||
expect(bottomHandlePositions(0)).toEqual([50])
|
||||
expect(bottomHandlePositions(0)).toEqual([])
|
||||
expect(bottomHandlePositions(-2)).toEqual([])
|
||||
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
|
||||
})
|
||||
})
|
||||
@@ -226,9 +230,11 @@ describe('handleId', () => {
|
||||
})
|
||||
|
||||
describe('clampHandles', () => {
|
||||
it('min is the side default (0 for L/R, 1 for T/B)', () => {
|
||||
expect(clampHandles('top', 0)).toBe(1)
|
||||
expect(clampHandles('bottom', -3)).toBe(1)
|
||||
it('min is 0 for every side, top/bottom included', () => {
|
||||
expect(clampHandles('top', 0)).toBe(0)
|
||||
expect(clampHandles('bottom', 0)).toBe(0)
|
||||
expect(clampHandles('top', -3)).toBe(0)
|
||||
expect(clampHandles('bottom', -3)).toBe(0)
|
||||
expect(clampHandles('left', -3)).toBe(0)
|
||||
expect(clampHandles('right', 0)).toBe(0)
|
||||
})
|
||||
@@ -236,9 +242,10 @@ describe('clampHandles', () => {
|
||||
expect(clampHandles('top', 9999)).toBe(MAX_HANDLES)
|
||||
expect(clampHandles('left', 65)).toBe(64)
|
||||
})
|
||||
it('non-finite / non-number falls back to side min', () => {
|
||||
it('non-finite / non-number falls back to the side default', () => {
|
||||
expect(clampHandles('left', NaN)).toBe(0)
|
||||
expect(clampHandles('top', undefined)).toBe(1)
|
||||
expect(clampHandles('bottom', NaN)).toBe(1)
|
||||
expect(clampHandles('right', '4' as unknown)).toBe(0)
|
||||
})
|
||||
it('floors fractional values', () => {
|
||||
@@ -259,9 +266,9 @@ describe('handlePositions', () => {
|
||||
expect(handlePositions('left', 0)).toEqual([])
|
||||
expect(handlePositions('right', 0)).toEqual([])
|
||||
})
|
||||
it('top/bottom clamp 0 up to 1 (never empty)', () => {
|
||||
expect(handlePositions('top', 0)).toEqual([50])
|
||||
expect(handlePositions('bottom', 0)).toEqual([50])
|
||||
it('top/bottom accept 0 and render nothing', () => {
|
||||
expect(handlePositions('top', 0)).toEqual([])
|
||||
expect(handlePositions('bottom', 0)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -307,3 +314,30 @@ describe('SIDES', () => {
|
||||
expect([...SIDES].sort()).toEqual(['bottom', 'left', 'right', 'top'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('zero connection points on top/bottom (issue: min was 1)', () => {
|
||||
it('MIN_HANDLES is 0', () => {
|
||||
expect(MIN_HANDLES).toBe(0)
|
||||
})
|
||||
|
||||
it('sideHandleCount honours an explicit 0 on top and bottom', () => {
|
||||
const data = { label: 'n', top_handles: 0, bottom_handles: 0 } as never
|
||||
expect(sideHandleCount(data, 'top')).toBe(0)
|
||||
expect(sideHandleCount(data, 'bottom')).toBe(0)
|
||||
})
|
||||
|
||||
it('still defaults top/bottom to 1 when the field is missing', () => {
|
||||
const data = { label: 'n' } as never
|
||||
expect(sideHandleCount(data, 'top')).toBe(1)
|
||||
expect(sideHandleCount(data, 'bottom')).toBe(1)
|
||||
})
|
||||
|
||||
it('renders no handle position for a 0-count top/bottom side', () => {
|
||||
expect(handlePositions('top', 0)).toEqual([])
|
||||
expect(handlePositions('bottom', 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('removes every top handle when going 2 → 0', () => {
|
||||
expect(removedHandleIds('top', 2, 0)).toEqual(new Set(['top', 'top-2']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,12 +65,43 @@ describe('parseYamlToCanvas', () => {
|
||||
expect(d.cpu_count).toBe(16)
|
||||
expect(d.ram_gb).toBe(64)
|
||||
expect(d.disk_gb).toBe(2000)
|
||||
expect(d.show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
it('sets show_hardware only when hardware fields present', () => {
|
||||
// A node draws its `properties`; the hardware fields are inventory data. An
|
||||
// import that set `show_hardware` alone showed the specs until the first
|
||||
// save, then lost them — so the specs arrive as properties instead.
|
||||
it('mints the hardware specs as visible properties', () => {
|
||||
const yaml = `
|
||||
- nodeType: proxmox
|
||||
label: "PVE1"
|
||||
cpuModel: "Intel Xeon"
|
||||
cpuCore: 16
|
||||
ram: 64
|
||||
disk: 2000
|
||||
`
|
||||
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||
expect(nodes[0].data.properties).toEqual([
|
||||
{ key: 'CPU Model', value: 'Intel Xeon', icon: 'Cpu', visible: true },
|
||||
{ key: 'CPU Cores', value: '16', icon: 'Cpu', visible: true },
|
||||
{ key: 'RAM', value: '64 GB', icon: 'MemoryStick', visible: true },
|
||||
{ key: 'Disk', value: '2000 GB', icon: 'HardDrive', visible: true },
|
||||
])
|
||||
// The structured fields survive for the export round trip.
|
||||
expect(nodes[0].data.cpu_count).toBe(16)
|
||||
})
|
||||
|
||||
it('mints only the specs the YAML carries', () => {
|
||||
const yaml = `- nodeType: server\n label: "RamOnly"\n ram: 8\n`
|
||||
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||
expect(nodes[0].data.properties).toEqual([
|
||||
{ key: 'RAM', value: '8 GB', icon: 'MemoryStick', visible: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a node with no hardware without properties', () => {
|
||||
const yaml = `- nodeType: server\n label: "NoHW"\n`
|
||||
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||
expect(nodes[0].data.properties).toBeUndefined()
|
||||
expect(nodes[0].data.show_hardware).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,21 +3,23 @@ import { serviceToForm, EMPTY_SERVICE_FORM } from '../serviceForm'
|
||||
|
||||
describe('serviceToForm', () => {
|
||||
it('maps a service onto string form fields', () => {
|
||||
expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', icon: 'database' })).toEqual({
|
||||
expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', host: 'svc.example.com', icon: 'database' })).toEqual({
|
||||
service_name: 'svc',
|
||||
port: '443',
|
||||
protocol: 'udp',
|
||||
path: '/x',
|
||||
host: 'svc.example.com',
|
||||
icon: 'database',
|
||||
})
|
||||
})
|
||||
|
||||
it('blanks out absent port and path', () => {
|
||||
it('blanks out absent port, path and host', () => {
|
||||
expect(serviceToForm({ protocol: 'tcp', service_name: 'svc' })).toEqual({
|
||||
service_name: 'svc',
|
||||
port: '',
|
||||
protocol: 'tcp',
|
||||
path: '',
|
||||
host: '',
|
||||
icon: undefined,
|
||||
})
|
||||
})
|
||||
@@ -27,6 +29,6 @@ describe('serviceToForm', () => {
|
||||
})
|
||||
|
||||
it('starts from a blank tcp form', () => {
|
||||
expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '' })
|
||||
expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '', host: '' })
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user