Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390a712f14 | ||
|
|
9daea4c4d0 | ||
|
|
2e642815f4 | ||
|
|
c6d6b525ea | ||
|
|
fbb660504e | ||
|
|
1be96d1045 | ||
|
|
9bc02d61c2 | ||
|
|
21a64e52ab | ||
|
|
ad0b1b427d | ||
|
|
4c460cec1c | ||
|
|
1aa88de491 | ||
|
|
d0679ad72b | ||
|
|
5df70ce128 | ||
|
|
a58725c2ee | ||
|
|
466df59b0c | ||
|
|
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,73 @@ 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.5] - 2026-08-21
|
||||
|
||||
### Features
|
||||
|
||||
- Deep-scan a single device from its inventory detail: the Services section gets a **Deep scan** button that sweeps the device over a port range you pick, prefilled with the full 1-65535 range and narrowable when you know where the service lives. The run is a normal Scan Run, so stop, progress and Scan History work as usual, and the fresh services are merged in — never replacing what you added by hand. (#363)
|
||||
|
||||
### Fixes
|
||||
|
||||
- A deep rescan of a slow host no longer comes back empty. The run hit its timeout and nmap discarded every port it had already found; the full range is now scanned in slices, a partial sweep is reported as partial instead of passed off as complete, and the timeout is a total budget between slices rather than an nmap flag. (#363)
|
||||
- A scan no longer repaints the service icons you picked by hand: the fingerprint's guess overwrote your choice, and cleared it outright on a port no signature covers. Since 3.3.0 the inventory row is the only copy, so one scan changed the service on every canvas at once. (#363)
|
||||
- A rescan finishing while you edit a device no longer throws the edit away, and it no longer tags every device it touches as network-discovered — a Proxmox guest, a rack mount or a hand-added host keeps its own source. A failed run is also recorded as `error`, the status Scan History filters and colours. (#363)
|
||||
- A property with no value gets the full node width for its label instead of being truncated against empty space. (#362)
|
||||
- A same-origin OIDC deployment behind a reverse proxy no longer answers 403 to every write. The CSRF check validated the browser's `Origin` against `CORS_ORIGINS` alone; the app's own origin, taken from `OIDC_REDIRECT_URI`, is accepted too, and startup warns when `CORS_ORIGINS` omits it. (#360)
|
||||
|
||||
## [3.3.4] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- A network scan no longer deletes the services you added by hand. The scanner replaced a device's whole service list with what it fingerprinted, so one scan wiped manual entries on every canvas at once and brought back the ones you had deleted. It merges now. (#347)
|
||||
- A node whose services were all replaced under it no longer renders empty: when nothing a node's view names is left on the device, the device's own list is drawn instead of hiding everything. (#347)
|
||||
- Properties added while running 3.3.0–3.3.2 survive the 3.3.3 upgrade. The view recovered from the pre-3.3.0 backup could not know about them, so they were hidden on every canvas; they are kept visible, and the per-canvas arrangement the backup does know is still restored. (#347)
|
||||
|
||||
## [3.3.3] - 2026-08-17
|
||||
|
||||
### Features
|
||||
|
||||
- Each node now keeps its own view of a device's services and properties: order and visibility are per node, so a service the scanner fingerprinted no longer appears on every canvas drawing that device, and a property added on one schematic stays there. The facts still live on the inventory row — hiding is per node, deleting is device-wide. Upgrades keep what each canvas showed today. (#357)
|
||||
|
||||
### Fixes
|
||||
|
||||
- The one-off view seed no longer re-opens the pre-upgrade backup on every boot: a node pointing at a deleted device kept matching the seed query and logged a recovery that recovered nothing. (#357)
|
||||
|
||||
## [3.3.2] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- Devices upgraded from 3.2.0 keep their ip, services, hostname, notes and hardware. The 3.3.0 migration aborted on timestamps SQLite returns as text, so nothing was carried across to the Device Inventory and every device read back blank. (#347, #348)
|
||||
- A canvas saved while an earlier migration was stuck no longer loses its device facts: the migration now fills the blank inventory row that save created, instead of skipping the node and dropping the columns holding its only copy. (#347)
|
||||
|
||||
## [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,
|
||||
)
|
||||
)
|
||||
|
||||
+309
-123
@@ -22,10 +22,24 @@ 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.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device, find_duplicate_node
|
||||
from app.services.scanner import (
|
||||
DeepScanOptions,
|
||||
_valid_port_range,
|
||||
_valid_port_spec,
|
||||
request_cancel,
|
||||
run_device_scan,
|
||||
run_scan,
|
||||
)
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
merge_zigbee_properties,
|
||||
@@ -169,7 +183,39 @@ async def _background_scan(
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run and run.status == "running":
|
||||
run.status = "failed"
|
||||
# "error", the same word run_scan / run_device_scan write when
|
||||
# they fail themselves — one condition, one name. Scan History
|
||||
# filters and colours that one; "failed" showed up unlabelled.
|
||||
run.status = "error"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _background_device_scan(
|
||||
run_id: str,
|
||||
device_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
full_ports: bool = True,
|
||||
ports: str | None = None,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await run_device_scan(
|
||||
device_id,
|
||||
db,
|
||||
run_id,
|
||||
deep_scan=deep_scan or DeepScanOptions(),
|
||||
full_ports=full_ports,
|
||||
ports=ports,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Device scan run %s failed unexpectedly", run_id)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run and run.status == "running":
|
||||
# "error", the same word run_scan / run_device_scan write when
|
||||
# they fail themselves — one condition, one name. Scan History
|
||||
# filters and colours that one; "failed" showed up unlabelled.
|
||||
run.status = "error"
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -208,6 +254,78 @@ async def trigger_scan(
|
||||
return run
|
||||
|
||||
|
||||
class RescanDeviceRequest(BaseModel):
|
||||
"""Per-device deep rescan. Defaults to every TCP port — that is the point.
|
||||
|
||||
``ports`` narrows the sweep to what the user typed in the deep-scan dialog
|
||||
(``80,443``, ``1-1024``); it wins over ``full_ports``.
|
||||
"""
|
||||
|
||||
full_ports: bool = True
|
||||
ports: str | None = None
|
||||
http_probe_enabled: bool | None = None
|
||||
verify_tls: bool | None = None
|
||||
|
||||
@field_validator("ports")
|
||||
@classmethod
|
||||
def _check_ports(cls, v: str | None) -> str | None:
|
||||
if v is None or not v.strip():
|
||||
return None
|
||||
spec = v.strip()
|
||||
if not _valid_port_spec(spec):
|
||||
raise ValueError("Invalid port range")
|
||||
return spec
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/rescan", response_model=ScanRunResponse)
|
||||
async def rescan_device(
|
||||
device_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: RescanDeviceRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Deep-rescan one known device to refresh its services (issue #350).
|
||||
|
||||
Recorded as a ScanRun like any other scan, so progress, stop and history
|
||||
work unchanged. One run per device at a time — a second request while the
|
||||
first is still scanning is a 409, not a duplicate nmap over 65535 ports.
|
||||
"""
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if not device.ip:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Device has no IP address to scan"
|
||||
)
|
||||
if device.status == "hidden":
|
||||
raise HTTPException(status_code=409, detail="Device is hidden")
|
||||
|
||||
target = f"{device.ip}/32"
|
||||
running = (await db.execute(
|
||||
select(ScanRun).where(ScanRun.status == "running", ScanRun.kind == "device")
|
||||
)).scalars().all()
|
||||
if any(target in (r.ranges or []) for r in running):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A scan is already running for this device"
|
||||
)
|
||||
|
||||
p = payload or RescanDeviceRequest()
|
||||
deep_scan = _resolve_deep_scan(
|
||||
TriggerScanRequest(
|
||||
http_probe_enabled=p.http_probe_enabled, verify_tls=p.verify_tls
|
||||
)
|
||||
)
|
||||
run = ScanRun(status="running", kind="device", ranges=[target])
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(
|
||||
_background_device_scan, run.id, device_id, deep_scan, p.full_ports, p.ports
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/{run_id}/stop", response_model=dict)
|
||||
async def stop_scan(
|
||||
run_id: str,
|
||||
@@ -244,65 +362,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 +432,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 +474,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 +495,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 +581,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 +602,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 +627,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 +812,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 +930,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:
|
||||
@@ -871,6 +1044,19 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=ScanRunResponse)
|
||||
async def get_run(
|
||||
run_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""One run, for a caller waiting on a scan it started (the device rescan)."""
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Scan run not found")
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/config", response_model=ScanConfig)
|
||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||
return ScanConfig(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,12 +2,21 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def origin_of(url: str) -> str:
|
||||
"""`scheme://host[:port]` of an absolute URL, or "" when it has none."""
|
||||
parts = urlsplit(url)
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return ""
|
||||
return f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
def _read_version() -> str:
|
||||
for candidate in [
|
||||
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
||||
@@ -88,6 +97,16 @@ class Settings(BaseSettings):
|
||||
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
|
||||
if "*" in self.cors_origins:
|
||||
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
|
||||
app_origin = origin_of(self.oidc_redirect_uri)
|
||||
allowed = {value.rstrip("/") for value in self.cors_origins}
|
||||
if app_origin and app_origin not in allowed:
|
||||
logger.warning(
|
||||
"CORS_ORIGINS does not list %s (the origin of OIDC_REDIRECT_URI). "
|
||||
"The CSRF check accepts that origin anyway, but add it to CORS_ORIGINS "
|
||||
"so browser requests are not rejected: CORS_ORIGINS=[\"%s\"]",
|
||||
app_origin,
|
||||
app_origin,
|
||||
)
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
@@ -98,6 +117,14 @@ class Settings(BaseSettings):
|
||||
# ports survive a timeout regardless; raise this on slow/overlay networks.
|
||||
scanner_version_host_timeout: int = 60
|
||||
|
||||
# Per-device deep rescan: total time budget in seconds, checked between port
|
||||
# slices. Unprivileged nmap falls back to a connect scan (-sT), and a host
|
||||
# that drops packets makes every filtered port wait out its RTT — the whole
|
||||
# range against such a host runs ~20-45 min. When the budget is spent the
|
||||
# run keeps what it found and says how much it did not reach. This is NOT an
|
||||
# nmap --host-timeout: that one discards a host's results wholesale.
|
||||
scanner_deep_host_timeout: int = 2700
|
||||
|
||||
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
|
||||
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
||||
scanner_http_ranges: list[str] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,7 +14,7 @@ from jwt import InvalidTokenError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.config import origin_of, settings
|
||||
|
||||
ACCESS_TOKEN_USE = "access"
|
||||
OIDC_SESSION_TOKEN_USE = "oidc_session"
|
||||
@@ -153,6 +153,13 @@ def origin_is_allowed(origin: str | None) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
|
||||
# A same-origin deployment behind a reverse proxy needs no CORS, so
|
||||
# CORS_ORIGINS is often left at its localhost default — yet browsers still
|
||||
# send Origin on unsafe methods. The OIDC callback URL is served by this app,
|
||||
# so its origin is the app's own and is always a legitimate CSRF origin.
|
||||
app_origin = origin_of(settings.oidc_redirect_uri)
|
||||
if app_origin:
|
||||
allowed_origins.add(app_origin)
|
||||
return origin.rstrip("/") in allowed_origins
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+326
-2
@@ -1,12 +1,14 @@
|
||||
import json as _json
|
||||
import logging
|
||||
import shutil
|
||||
import sqlite3
|
||||
import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from contextlib import closing, 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 +428,328 @@ 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)",
|
||||
),
|
||||
# Which of the row's services and properties this canvas shows, and
|
||||
# in what order. Seeded from the row further down, once the backfill
|
||||
# has had its say — see `_seed_node_views`.
|
||||
("nodes.display_view", "ALTER TABLE nodes ADD COLUMN display_view JSON"),
|
||||
):
|
||||
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()
|
||||
await _seed_node_views()
|
||||
|
||||
|
||||
|
||||
# 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,"
|
||||
"display_view JSON,"
|
||||
"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, display_view, 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)
|
||||
|
||||
|
||||
def _pre_split_backup() -> Path | None:
|
||||
"""The newest backup still holding the per-node device columns, if any.
|
||||
|
||||
`_backup_db` copies the database *before* the migrations of each new
|
||||
version, so a user who upgraded to 3.3.0 has a `homelab.db.back-3.3.0`
|
||||
carrying the last state in which `nodes` still owned its own services and
|
||||
properties. That copy is the only record of which canvas showed what, since
|
||||
3.3.0's backfill unioned them all onto one inventory row. Newest first: it is
|
||||
the state closest to the upgrade, so it is what the user last saw.
|
||||
"""
|
||||
db_path = Path(settings.sqlite_path)
|
||||
candidates = sorted(
|
||||
db_path.parent.glob(f"{db_path.name}.back-*"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in candidates:
|
||||
try:
|
||||
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as conn:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(nodes)")}
|
||||
except sqlite3.Error:
|
||||
continue
|
||||
if {"services", "properties"} <= cols:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _views_from_backup() -> dict[str, dict[str, Any]]:
|
||||
"""What each node drew, read out of the pre-3.3.0 backup. Empty when there is none.
|
||||
|
||||
Keyed by node id, which is a uuid and stable across every version. A backup
|
||||
that cannot be opened, or holds nodes this database no longer has, simply
|
||||
contributes nothing — the caller falls back to the inventory row.
|
||||
"""
|
||||
path = _pre_split_backup()
|
||||
if path is None:
|
||||
return {}
|
||||
try:
|
||||
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as conn:
|
||||
rows = conn.execute("SELECT id, services, properties FROM nodes").fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("Could not read the pre-3.3.0 node lists from %s: %s", path.name, exc)
|
||||
return {}
|
||||
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for node_id, services, properties in rows:
|
||||
drawn: dict[str, Any] = {}
|
||||
for kind, raw in (("services", services), ("properties", properties)):
|
||||
if isinstance(raw, str | bytes):
|
||||
with suppress(ValueError):
|
||||
decoded = _json.loads(raw)
|
||||
if isinstance(decoded, list):
|
||||
drawn[kind] = decoded
|
||||
if drawn:
|
||||
out[node_id] = drawn
|
||||
if out:
|
||||
logger.info("Recovering the per-canvas service/property layout from %s", path.name)
|
||||
return out
|
||||
|
||||
|
||||
async def _seed_node_views() -> None:
|
||||
"""Give pre-existing nodes an explicit view of their inventory row (3.3.3).
|
||||
|
||||
Order and visibility for services and properties moved to the node, so one
|
||||
device drawn on two canvases can be rendered two ways. A node from before
|
||||
that has no view, and where it comes from decides whether the user gets
|
||||
their arrangement back:
|
||||
|
||||
* upgrading from 3.2.0, the backfill has already seeded it from the node's
|
||||
own columns — nothing here to do;
|
||||
* upgrading from 3.3.0-3.3.2, those columns are gone and the row holds the
|
||||
union of every canvas, so the view is recovered from the backup taken
|
||||
before the 3.3.0 migration. That is the difference between a canvas coming
|
||||
back as the user left it and coming back showing every other canvas'
|
||||
properties;
|
||||
* with no usable backup, the row itself is the seed: what shows today keeps
|
||||
showing, and only what the row gains *later* is held back.
|
||||
|
||||
Never fatal: without a view the node simply shows the whole row, which is
|
||||
the behaviour it has now.
|
||||
"""
|
||||
# Imported here: app.services imports app.db.models, which imports this module.
|
||||
from app.services.inventory_sync import seed_node_views
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
seeded = await seed_node_views(session, drawn=_views_from_backup)
|
||||
if seeded:
|
||||
await session.commit()
|
||||
logger.info("Seeded the service/property view of %d node(s)", seeded)
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Seeding the node service/property views failed: %s", exc)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
+50
-19
@@ -28,44 +28,49 @@ 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
|
||||
)
|
||||
# How this canvas renders the device's list facts: which services and which
|
||||
# properties it shows, and in what order. The facts themselves stay on the
|
||||
# inventory row, so the same device drawn on two canvases can show two
|
||||
# different subsets — a scanner-guessed service on one, none on the other.
|
||||
# {"services": [{"key": "443|tcp|https", "visible": true}, …],
|
||||
# "properties": [{"key": "rack", "visible": false}, …]}
|
||||
# NULL only for canvas furniture and for a node with no inventory row yet;
|
||||
# `inventory_sync.link_facts` fills both lists as soon as there is one.
|
||||
display_view: Mapped[dict[str, Any] | None] = mapped_column(JSON, 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 +241,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,983 @@
|
||||
"""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 Callable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
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())
|
||||
|
||||
|
||||
# --- Per-node view of the device's list facts -----------------------------
|
||||
#
|
||||
# The row owns the services and the properties; a node owns which of them it
|
||||
# shows and in what order. Both are keyed by a stable string so the view
|
||||
# survives an edit to a service's path or a property's value.
|
||||
|
||||
VIEW_LISTS = ("services", "properties")
|
||||
|
||||
|
||||
def _service_view_key(svc: Any) -> str:
|
||||
port, protocol, name = _service_key(svc) if isinstance(svc, dict) else (None, None, repr(svc))
|
||||
return f"{port}|{protocol}|{name}"
|
||||
|
||||
|
||||
def _property_view_key(prop: Any) -> str:
|
||||
if not isinstance(prop, dict):
|
||||
return repr(prop)
|
||||
return str(prop.get("key") or "").lower()
|
||||
|
||||
|
||||
_VIEW_KEY = {"services": _service_view_key, "properties": _property_view_key}
|
||||
|
||||
|
||||
def view_entries(items: list[Any] | None, kind: str) -> list[dict[str, Any]]:
|
||||
"""One list of device facts as a node's view of it: order plus visibility.
|
||||
|
||||
A service carries no ``visible`` of its own — one that reached a canvas was
|
||||
always drawn — so it defaults to shown. A property carries an explicit flag
|
||||
and keeps it. Duplicate keys collapse: the view addresses the row, and the
|
||||
row holds one entry per key.
|
||||
"""
|
||||
key_of = _VIEW_KEY[kind]
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in items or []:
|
||||
key = key_of(item)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
visible = bool(item.get("visible", True)) if isinstance(item, dict) else True
|
||||
out.append({"key": key, "visible": visible})
|
||||
return out
|
||||
|
||||
|
||||
def view_from_facts(facts: Mapping[str, Any]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""The view a node payload implies — only for the lists it actually sent.
|
||||
|
||||
The wire shape has no separate view: a client sends its services and its
|
||||
properties in display order, each with its ``visible`` flag, exactly as it
|
||||
draws them. That *is* the view, so it is read back out here rather than
|
||||
asking clients for a second field.
|
||||
"""
|
||||
return {kind: view_entries(facts[kind], kind) for kind in VIEW_LISTS if kind in facts}
|
||||
|
||||
|
||||
def view_of_device(device: InventoryDevice) -> dict[str, list[dict[str, Any]]]:
|
||||
"""A view showing everything the row currently holds — the seed for a new node."""
|
||||
return {
|
||||
"services": view_entries(device.services, "services"),
|
||||
"properties": view_entries(device.properties, "properties"),
|
||||
}
|
||||
|
||||
|
||||
def next_view(
|
||||
current: Mapping[str, Any] | None,
|
||||
incoming: Mapping[str, Any],
|
||||
device: InventoryDevice | None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""This node's view after a write: what it sent, then the row for the rest.
|
||||
|
||||
A list the write did not carry keeps the view it had. A node linked to a row
|
||||
always ends up with both lists, so "not in the view" can mean one thing
|
||||
only: this canvas does not show it. That is what keeps a service a later scan
|
||||
discovers off every canvas until someone turns it on.
|
||||
|
||||
A node getting its *first* view is the exception: an empty list there means
|
||||
the writer had nothing to say about it, not that the user hid everything —
|
||||
creating a node for an already-scanned device sends no services and must
|
||||
still draw the ones the row holds. ``strict`` turns that off for the one
|
||||
caller whose empty list is a real answer: the legacy backfill, where the
|
||||
node's own columns are the whole of what that canvas used to show.
|
||||
"""
|
||||
if device is None:
|
||||
return dict(current) if current else None
|
||||
out: dict[str, Any] = dict(current or {})
|
||||
first_view = current is None
|
||||
seed = view_of_device(device)
|
||||
for kind in VIEW_LISTS:
|
||||
entries = incoming.get(kind)
|
||||
if entries or (entries is not None and (strict or not first_view)):
|
||||
out[kind] = entries
|
||||
elif kind not in out:
|
||||
out[kind] = seed[kind]
|
||||
return out
|
||||
|
||||
|
||||
def apply_view(items: list[Any] | None, entries: Any, kind: str) -> list[Any]:
|
||||
"""The row's facts as one node draws them: its order, its visibility.
|
||||
|
||||
Without a view — furniture, or a node whose row was linked by an older
|
||||
version — everything shows, in the row's own order. With one, an item the
|
||||
view does not list is appended hidden rather than dropped, so a service a
|
||||
scan added is one toggle away instead of invisible.
|
||||
|
||||
A *non-empty* view that matches nothing the row still holds is treated as
|
||||
having no view at all. It means the row was replaced wholesale under the
|
||||
node — every key gone, every key new — and hiding the lot would leave a node
|
||||
drawing nothing while its view claims otherwise. An empty view is different:
|
||||
it is a real answer ("this canvas draws none of them") and keeps hiding
|
||||
everything.
|
||||
"""
|
||||
key_of = _VIEW_KEY[kind]
|
||||
facts = list(items or [])
|
||||
if not isinstance(entries, list):
|
||||
return facts
|
||||
|
||||
by_key: dict[str, Any] = {}
|
||||
for item in facts:
|
||||
by_key.setdefault(key_of(item), item)
|
||||
|
||||
out: list[Any] = []
|
||||
taken: set[str] = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
key = str(entry.get("key"))
|
||||
item = by_key.get(key)
|
||||
if item is None or key in taken:
|
||||
continue # Deleted from the row since — the view catches up on write.
|
||||
taken.add(key)
|
||||
out.append(_stamped(item, bool(entry.get("visible", True))))
|
||||
if entries and not taken:
|
||||
return facts
|
||||
for item in facts:
|
||||
if key_of(item) not in taken:
|
||||
out.append(_stamped(item, False))
|
||||
return out
|
||||
|
||||
|
||||
def _stamped(item: Any, visible: bool) -> Any:
|
||||
"""``item`` carrying this node's verdict on whether it is drawn.
|
||||
|
||||
A shown item that never had a ``visible`` key does not gain one: services
|
||||
have always travelled without it, and readers treat its absence as shown.
|
||||
Only hiding is news, and properties keep the explicit flag they arrived with.
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
if visible and "visible" not in item:
|
||||
return dict(item)
|
||||
return {**item, "visible": visible}
|
||||
|
||||
|
||||
# How a service looks is the user's call. A fingerprint only ever guesses it
|
||||
# from a port number and a banner, so a scan that re-finds a known service must
|
||||
# not repaint the icon someone picked by hand — that happened on every "Scan
|
||||
# network", across every canvas drawing the device, at once.
|
||||
_CURATED_SERVICE_FIELDS = ("icon", "category")
|
||||
|
||||
|
||||
def merge_services(
|
||||
base: list[Any] | None,
|
||||
incoming: list[Any] | None,
|
||||
*,
|
||||
discovered: bool = False,
|
||||
) -> list[Any]:
|
||||
"""Union two service lists on (port, protocol, name); incoming wins.
|
||||
|
||||
``discovered`` marks ``incoming`` as scanner output rather than a user edit:
|
||||
it still adds services and refreshes facts, but leaves an established icon
|
||||
and category alone. Either way a blank incoming value never clears one that
|
||||
is already set — an absent field is silence, not a reset.
|
||||
"""
|
||||
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):
|
||||
merged = {**out[pos], **svc}
|
||||
for field_name in _CURATED_SERVICE_FIELDS:
|
||||
established = out[pos].get(field_name)
|
||||
if established and (discovered or not svc.get(field_name)):
|
||||
merged[field_name] = established
|
||||
out[pos] = merged
|
||||
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,
|
||||
strict_view: bool = False,
|
||||
) -> 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
|
||||
# Order and visibility are this node's, not the device's, so they are taken
|
||||
# from the full payload — never from the `changed_fields`/`only_changed`
|
||||
# narrowing above, which exists to protect the *shared* row from a stale
|
||||
# snapshot. A node's own view has no other writer to collide with.
|
||||
node.display_view = next_view(
|
||||
node.display_view, view_from_facts(facts), device, strict=strict_view
|
||||
)
|
||||
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} - _NODE_DERIVED_COLUMNS
|
||||
return {k: v for k, v in payload.items() if k in allowed}
|
||||
|
||||
|
||||
# Node columns the server derives rather than accepts: `display_view` is read
|
||||
# back out of the services and properties a payload carries (see
|
||||
# :func:`view_from_facts`), so a client sending one directly is ignored.
|
||||
_NODE_DERIVED_COLUMNS = frozenset({"display_view"})
|
||||
|
||||
|
||||
# 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
|
||||
}
|
||||
# An implementation detail of the split, not part of the wire shape: the
|
||||
# view is reported *through* the services and properties it orders.
|
||||
view = payload.pop("display_view", None) or {}
|
||||
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"] = apply_view(device.services, view.get("services"), "services")
|
||||
payload["properties"] = apply_view(device.properties, view.get("properties"), "properties")
|
||||
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
|
||||
|
||||
|
||||
# Legacy `nodes` columns SQLite stores as DATETIME. Read through raw SQL they come
|
||||
# back as text, and writing text into a DateTime column raises
|
||||
# ``TypeError: SQLite DateTime type only accepts Python datetime and date objects``.
|
||||
_LEGACY_DATETIME_COLUMNS = ("last_seen", "last_scan")
|
||||
|
||||
|
||||
def _decode_dt(value: Any) -> datetime | None:
|
||||
"""Raw SQL hands back DATETIME columns as text; the ORM would have parsed them.
|
||||
|
||||
An unparseable stamp becomes ``None`` rather than an error: `last_seen` and
|
||||
`last_scan` are observations the status checker refreshes within a minute,
|
||||
and losing one must not cost a node its inventory row.
|
||||
"""
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip())
|
||||
except ValueError:
|
||||
logger.debug("Ignoring an unparseable legacy timestamp: %r", value)
|
||||
return None
|
||||
|
||||
|
||||
async def _row_is_missing_facts(db: AsyncSession, device_id: str, facts: Mapping[str, Any]) -> bool:
|
||||
"""Does the linked row lack a fact the node's legacy columns still hold?
|
||||
|
||||
True only where the node has something to give and the row has nothing in
|
||||
that field, so a node whose facts already made it across is left alone and
|
||||
the backfill stays a no-op on later boots.
|
||||
"""
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device is None:
|
||||
return True # The row is gone; the node needs a new one.
|
||||
for field in (*DEVICE_SCALARS, "label", "type", "ieee_address"):
|
||||
if not _blank(facts.get(field)) and _blank(getattr(device, field, None)):
|
||||
return True
|
||||
return any(
|
||||
facts.get(field) and not getattr(device, field, None)
|
||||
for field in ("services", "properties")
|
||||
)
|
||||
|
||||
|
||||
def _with_later_properties(
|
||||
view: dict[str, Any] | None, device: InventoryDevice
|
||||
) -> dict[str, Any] | None:
|
||||
"""``view``, plus the row's properties the pre-3.3.0 backup never saw.
|
||||
|
||||
They are what the user added while running 3.3.0-3.3.2 — the row was the
|
||||
only place to add them then, and every canvas drew them. Recovering the old
|
||||
view alone would take them off every canvas at once, which reads as data
|
||||
loss. Appended in the row's order, after everything the backup placed.
|
||||
"""
|
||||
if view is None:
|
||||
return None
|
||||
entries = view.get("properties")
|
||||
if not isinstance(entries, list):
|
||||
return view
|
||||
listed = {str(e.get("key")) for e in entries if isinstance(e, dict)}
|
||||
later = [e for e in view_entries(device.properties, "properties") if e["key"] not in listed]
|
||||
if not later:
|
||||
return view
|
||||
return {**view, "properties": [*entries, *later]}
|
||||
|
||||
|
||||
async def seed_node_views(
|
||||
db: AsyncSession, *, drawn: Callable[[], Mapping[str, Mapping[str, Any]]] | None = None
|
||||
) -> int:
|
||||
"""Give every linked node with no view one it can be held to.
|
||||
|
||||
Runs once, on the boot that adds `nodes.display_view`. Without it every
|
||||
pre-existing node would fall through to "no view, show everything" and the
|
||||
next scan would push a newly fingerprinted service onto all of them at once —
|
||||
the leak this column exists to stop.
|
||||
|
||||
``drawn`` returns a map of node id to the services and properties that node
|
||||
itself carried before 3.3.0 unioned them onto the row — the caller reads
|
||||
them out of the pre-upgrade backup, and is only asked to when there is
|
||||
actually a node to seed. Where it has an answer that answer wins, restoring
|
||||
the arrangement the user made; where it does not, the row is the seed and
|
||||
the canvas keeps showing exactly what it shows today. Does not commit.
|
||||
|
||||
The backup is 3.2.0-era, so it can only speak for what existed then. A
|
||||
*property* the row has gained since — one the user added by hand while
|
||||
running 3.3.0-3.3.2, when the row was all a canvas had — appears in no
|
||||
backup entry, and seeding strictly from the backup would hide work the user
|
||||
has been looking at for days. Those are appended visible, keeping the
|
||||
recovered order and hidden flags for everything the backup does know.
|
||||
Services are not treated that way: what a row gained since 3.3.0 is mostly a
|
||||
scan's fingerprint, and holding it back is the whole point of the view.
|
||||
"""
|
||||
nodes = list(
|
||||
(
|
||||
await db.execute(
|
||||
select(Node).where(
|
||||
# A `device_id` naming a row that no longer exists cannot be
|
||||
# seeded from anything, and leaving it in would match this
|
||||
# query on every later boot — re-reading the backup file and
|
||||
# logging a recovery that seeds nothing. Deleting a device
|
||||
# leaves exactly that: SQLite runs with foreign keys off, so
|
||||
# the `ON DELETE SET NULL` never fires on an existing table.
|
||||
Node.device_id.in_(select(InventoryDevice.id)),
|
||||
Node.display_view.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not nodes:
|
||||
return 0
|
||||
devices = await load_devices_for(db, nodes)
|
||||
# Read the backup only now: on every later boot this function returns above
|
||||
# and no file is opened at all.
|
||||
was_drawn = drawn() if drawn is not None else {}
|
||||
seeded = 0
|
||||
for node in nodes:
|
||||
device = devices.get(node.device_id or "")
|
||||
if device is None: # pragma: no cover - the query already excluded these
|
||||
continue
|
||||
was = was_drawn.get(node.id)
|
||||
# `strict`: an empty list in the backup is this canvas' real answer —
|
||||
# it drew no service — and must not be read as "say nothing, show all".
|
||||
if was:
|
||||
view = next_view(None, view_from_facts(was), device, strict=True)
|
||||
node.display_view = _with_later_properties(view, device)
|
||||
else:
|
||||
node.display_view = view_of_device(device)
|
||||
seeded += 1
|
||||
await db.flush()
|
||||
return seeded
|
||||
|
||||
|
||||
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))
|
||||
# Linked nodes are read too, not just unlinked ones. A canvas saved while an
|
||||
# earlier migration was stuck minted a *blank* inventory row from a UI that
|
||||
# had no facts to show, and linked the node to it. Skipping those would count
|
||||
# them as migrated and drop the columns that still hold their only copy of
|
||||
# the ip, services and notes. A linked node is filled, never overwritten.
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f"SELECT id, label, type, design_id, device_id, {placeholders} FROM nodes "
|
||||
f"WHERE 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 []
|
||||
for field in _LEGACY_DATETIME_COLUMNS:
|
||||
if field in facts:
|
||||
facts[field] = _decode_dt(facts[field])
|
||||
facts["label"] = row["label"]
|
||||
facts["type"] = row["type"]
|
||||
|
||||
# One savepoint per node: a row the merge cannot write 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. The catch is deliberately broad — a node that cannot be read
|
||||
# or written for *any* reason must not cost the others their link.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
if node.device_id and not await _row_is_missing_facts(db, node.device_id, facts):
|
||||
continue # Already migrated, and its row holds the facts.
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
# A node that is already linked only has its gaps filled: whatever
|
||||
# is on the row was written after the migration and is newer.
|
||||
device = await link_facts(
|
||||
db,
|
||||
node,
|
||||
facts,
|
||||
overwrite_scalars=node.device_id is None,
|
||||
# The node's own columns are exactly what this canvas drew
|
||||
# before the upgrade, empty lists included — so they define
|
||||
# its view outright. Anything else the row carries (a service
|
||||
# a scan fingerprinted, a property another canvas added)
|
||||
# stays hidden here rather than appearing on every canvas.
|
||||
strict_view=True,
|
||||
)
|
||||
if device is None:
|
||||
continue
|
||||
await db.flush()
|
||||
except Exception 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:
|
||||
|
||||
+353
-89
@@ -7,6 +7,7 @@ import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -15,10 +16,11 @@ 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
|
||||
from app.services.inventory_sync import merge_services
|
||||
from app.services.mac_utils import normalize_mac
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,8 +63,89 @@ def _valid_port_range(spec: str) -> bool:
|
||||
return len(parts) == 1 or parts[0] <= parts[1]
|
||||
|
||||
|
||||
def _build_port_spec(http_ranges: list[str] | None) -> str:
|
||||
"""Combine the default port list with validated user ranges for nmap -p."""
|
||||
# Every TCP port. Used by the per-device deep rescan, which trades minutes of
|
||||
# nmap time for a service list that no curated port list can promise.
|
||||
_FULL_PORTS = "1-65535"
|
||||
|
||||
# The deep rescan runs the full range in slices, one nmap call each, unioning
|
||||
# what they find. A slice that runs long costs only its own ports — where a
|
||||
# single 65535-port call that overruns costs everything (see _nmap_scan_single).
|
||||
# It also gives the run somewhere to notice a stop request, and somewhere to
|
||||
# give up when the budget is spent, without abandoning the ports already found.
|
||||
_DEEP_CHUNK_SIZE = 8192
|
||||
|
||||
|
||||
def _parse_port_spec(spec: str) -> list[tuple[int, int]]:
|
||||
"""Parse an nmap ``-p`` spec into sorted, merged ``(start, end)`` ranges.
|
||||
|
||||
Accepts what the user can type in the deep-scan dialog: ``80``,
|
||||
``8000-9000``, or a comma list of both. Returns ``[]`` for anything invalid
|
||||
— the caller turns that into a 422 rather than handing nmap a bad ``-p``.
|
||||
"""
|
||||
ranges: list[tuple[int, int]] = []
|
||||
for token in (t.strip() for t in spec.split(",")):
|
||||
if not token or not _valid_port_range(token):
|
||||
return []
|
||||
parts = [int(p) for p in token.split("-")]
|
||||
ranges.append((parts[0], parts[-1]))
|
||||
if not ranges:
|
||||
return []
|
||||
ranges.sort()
|
||||
merged = [ranges[0]]
|
||||
for start, end in ranges[1:]:
|
||||
last_start, last_end = merged[-1]
|
||||
if start <= last_end + 1:
|
||||
merged[-1] = (last_start, max(last_end, end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _valid_port_spec(spec: str) -> bool:
|
||||
"""True when ``spec`` is a usable comma list of ports/ranges."""
|
||||
return bool(_parse_port_spec(spec))
|
||||
|
||||
|
||||
def _port_chunks(spec: str, size: int = _DEEP_CHUNK_SIZE) -> list[str]:
|
||||
"""Slice a port spec into nmap ``-p`` specs of at most ``size`` ports each.
|
||||
|
||||
Ranges are packed, not scanned one call per range: ``80,443`` is one chunk,
|
||||
not two, while ``1-65535`` becomes eight. Each chunk is a scan boundary —
|
||||
where a stop request lands and where the time budget is checked.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
budget = size
|
||||
for start, end in _parse_port_spec(spec):
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
take = min(budget, end - cursor + 1)
|
||||
stop = cursor + take - 1
|
||||
current.append(f"{cursor}-{stop}" if stop > cursor else str(cursor))
|
||||
budget -= take
|
||||
cursor = stop + 1
|
||||
if budget == 0:
|
||||
chunks.append(",".join(current))
|
||||
current = []
|
||||
budget = size
|
||||
if current:
|
||||
chunks.append(",".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
def _deep_port_chunks(size: int = _DEEP_CHUNK_SIZE) -> list[str]:
|
||||
"""Slice the whole TCP range into nmap ``-p`` specs of ``size`` ports."""
|
||||
return _port_chunks(_FULL_PORTS, size)
|
||||
|
||||
|
||||
def _build_port_spec(http_ranges: list[str] | None, full: bool = False) -> str:
|
||||
"""Combine the default port list with validated user ranges for nmap -p.
|
||||
|
||||
``full`` overrides everything with the whole TCP range — the deep rescan of
|
||||
a single device, where completeness beats speed.
|
||||
"""
|
||||
if full:
|
||||
return _FULL_PORTS
|
||||
if not http_ranges:
|
||||
return _EXTRA_PORTS
|
||||
extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
|
||||
@@ -237,7 +320,9 @@ async def _ping_sweep(target: str, run_id: str | None = None) -> dict[str, dict[
|
||||
return alive
|
||||
|
||||
|
||||
def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
|
||||
def _nmap_scan_single(
|
||||
host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS, bounded: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Phase 2 — single-IP port scan with service detection.
|
||||
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
||||
@@ -261,8 +346,26 @@ def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS)
|
||||
# -sT without root but being explicit avoids edge cases.
|
||||
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
|
||||
|
||||
# --- Pass A: port discovery (no -sV, no host-timeout) ---
|
||||
# --- Pass A: port discovery (no -sV) ---
|
||||
# Default timing for the range scan: its ports are authoritative and a
|
||||
# curated port list is fast either way.
|
||||
#
|
||||
# ``bounded`` is the deep rescan's timing, and carries NO --host-timeout on
|
||||
# purpose: nmap answers a host timeout with "Skipping host <ip> due to host
|
||||
# timeout" and discards *every* port it had already found, so a ceiling here
|
||||
# turns a slow scan into one that reports nothing. The caller bounds total
|
||||
# runtime by slicing the range instead.
|
||||
#
|
||||
# What costs the time is a host that drops packets: 8188 of 8192 ports
|
||||
# filtered, each waiting out its probe. Measured against such a host, the
|
||||
# retry pass is the whole cost — 8192 ports took 329s at --max-retries 1
|
||||
# and 164s at 0, finding the same ports. Capping the RTT changed nothing
|
||||
# (329s), so it is not set. Dropping retries risks missing a port that
|
||||
# loses its one probe; that trade belongs to the deep scan alone, and the
|
||||
# curated-port range scan keeps nmap's default retries.
|
||||
discovery_args = f"{scan_type} --open -T4 -Pn -p {port_spec}"
|
||||
if bounded:
|
||||
discovery_args += " --max-retries 0 --min-rate 2000"
|
||||
logger.debug("[Phase 2] %s discovery args: %s", ip, discovery_args)
|
||||
nm_disc = nmap.PortScanner()
|
||||
try:
|
||||
@@ -501,6 +604,110 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
||||
return deleted
|
||||
|
||||
|
||||
async def process_host(
|
||||
db: AsyncSession,
|
||||
host: dict[str, Any],
|
||||
*,
|
||||
hidden_ips: set[str],
|
||||
deep_scan: DeepScanOptions,
|
||||
discovery_source: str = "arp",
|
||||
) -> str:
|
||||
"""Fold one scanned host into ``device_inventory`` and commit.
|
||||
|
||||
Shared by the range scan and the single-device deep rescan, so both apply
|
||||
the same matching, merge and de-duplication rules.
|
||||
|
||||
Returns ``"skipped"`` (hidden by the user), ``"created"`` (a new inventory
|
||||
row) or ``"updated"`` (an existing row refreshed).
|
||||
"""
|
||||
ip = host["ip"]
|
||||
|
||||
# Skip only user-hidden devices. On-canvas devices are kept so they
|
||||
# surface in the inventory with a canvas-presence badge.
|
||||
if ip in hidden_ips:
|
||||
logger.debug("Skipping %s — hidden by user", ip)
|
||||
return "skipped"
|
||||
|
||||
open_ports = host["open_ports"]
|
||||
# Deep-scan HTTP probe: enrich open ports with title/header signals so
|
||||
# fingerprint can confirm services on custom ports. No-op when disabled
|
||||
# or when the host has no open ports (e.g. mDNS-only discovery).
|
||||
if deep_scan.http_probe_enabled and open_ports:
|
||||
open_ports = await probe_open_ports(
|
||||
ip, open_ports, verify_tls=deep_scan.verify_tls
|
||||
)
|
||||
|
||||
norm_mac = normalize_mac(host.get("mac"))
|
||||
services = fingerprint_ports(open_ports)
|
||||
suggested_type = suggest_node_type(open_ports, norm_mac)
|
||||
|
||||
# One inventory row per device. Match by IP OR MAC across pending AND
|
||||
# approved so a re-scan refreshes the existing row instead of spawning
|
||||
# a duplicate — and so a device previously imported from Proxmox (which
|
||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
||||
# of doubling up. Hidden rows are already skipped above.
|
||||
match_cond = [InventoryDevice.ip == ip]
|
||||
if norm_mac:
|
||||
match_cond.append(InventoryDevice.mac == norm_mac)
|
||||
existing_rows = (await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(or_(*match_cond), InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
if existing_rows:
|
||||
# Prefer an approved row (it owns the canvas link semantics),
|
||||
# otherwise the oldest. Collapse any leftover duplicates created
|
||||
# by earlier scans.
|
||||
keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
|
||||
for dup in existing_rows:
|
||||
if dup is not keep:
|
||||
await db.delete(dup)
|
||||
keep.ip = keep.ip or ip # fill an IP a Proxmox import lacked
|
||||
keep.mac = norm_mac or keep.mac
|
||||
keep.hostname = host.get("hostname") or keep.hostname
|
||||
keep.os = host.get("os") or keep.os
|
||||
# Union, never replace. Since 3.3.0 the row is the only copy of
|
||||
# a device's services — every canvas drawing it reads this list
|
||||
# — so overwriting it with the fingerprint would delete services
|
||||
# the user added by hand, on every canvas at once. What a scan
|
||||
# finds is added; what it no longer sees stays. A service the
|
||||
# user does not want on a canvas is hidden by that node's view,
|
||||
# which is where "I deleted this" belongs.
|
||||
# discovered=: the fingerprint may add services and refresh what it
|
||||
# knows, but never repaints an icon or category the user chose.
|
||||
keep.services = merge_services(keep.services, services, discovered=True)
|
||||
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
|
||||
# scan guess; the importer knows the true type.
|
||||
if not (keep.ieee_address or "").startswith("pve-"):
|
||||
keep.suggested_type = suggested_type
|
||||
# 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)
|
||||
outcome = "updated"
|
||||
else:
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
mac=norm_mac,
|
||||
hostname=host.get("hostname"),
|
||||
os=host.get("os"),
|
||||
services=services,
|
||||
suggested_type=suggested_type,
|
||||
status="pending",
|
||||
discovery_source=discovery_source,
|
||||
discovery_sources=[discovery_source],
|
||||
last_scan=datetime.now(timezone.utc),
|
||||
))
|
||||
outcome = "created"
|
||||
|
||||
await db.commit()
|
||||
return outcome
|
||||
|
||||
|
||||
async def run_scan(
|
||||
ranges: list[str],
|
||||
db: AsyncSession,
|
||||
@@ -544,92 +751,17 @@ async def run_scan(
|
||||
|
||||
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
|
||||
nonlocal devices_found
|
||||
ip = host["ip"]
|
||||
|
||||
# Skip only user-hidden devices. On-canvas devices are kept so they
|
||||
# surface in the inventory with a canvas-presence badge.
|
||||
if ip in hidden_ips:
|
||||
logger.debug("Skipping %s — hidden by user", ip)
|
||||
outcome = await process_host(
|
||||
db,
|
||||
host,
|
||||
hidden_ips=hidden_ips,
|
||||
deep_scan=deep_scan,
|
||||
discovery_source=discovery_source,
|
||||
)
|
||||
if outcome == "skipped":
|
||||
return
|
||||
|
||||
open_ports = host["open_ports"]
|
||||
# Deep-scan HTTP probe: enrich open ports with title/header signals so
|
||||
# fingerprint can confirm services on custom ports. No-op when disabled
|
||||
# or when the host has no open ports (e.g. mDNS-only discovery).
|
||||
if deep_scan.http_probe_enabled and open_ports:
|
||||
open_ports = await probe_open_ports(
|
||||
ip, open_ports, verify_tls=deep_scan.verify_tls
|
||||
)
|
||||
|
||||
norm_mac = normalize_mac(host.get("mac"))
|
||||
services = fingerprint_ports(open_ports)
|
||||
suggested_type = suggest_node_type(open_ports, norm_mac)
|
||||
|
||||
# One inventory row per device. Match by IP OR MAC across pending AND
|
||||
# approved so a re-scan refreshes the existing row instead of spawning
|
||||
# a duplicate — and so a device previously imported from Proxmox (which
|
||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
||||
# of doubling up. Hidden rows are already skipped above.
|
||||
match_cond = [InventoryDevice.ip == ip]
|
||||
if norm_mac:
|
||||
match_cond.append(InventoryDevice.mac == norm_mac)
|
||||
existing_rows = (await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(or_(*match_cond), InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
if existing_rows:
|
||||
# Prefer an approved row (it owns the canvas link semantics),
|
||||
# otherwise the oldest. Collapse any leftover duplicates created
|
||||
# by earlier scans.
|
||||
keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
|
||||
for dup in existing_rows:
|
||||
if dup is not keep:
|
||||
await db.delete(dup)
|
||||
keep.ip = keep.ip or ip # fill an IP a Proxmox import lacked
|
||||
keep.mac = norm_mac or keep.mac
|
||||
keep.hostname = host.get("hostname") or keep.hostname
|
||||
keep.os = host.get("os") or keep.os
|
||||
keep.services = services
|
||||
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
|
||||
# scan guess; the importer knows the true type.
|
||||
if not (keep.ieee_address or "").startswith("pve-"):
|
||||
keep.suggested_type = suggested_type
|
||||
# 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.
|
||||
else:
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
mac=norm_mac,
|
||||
hostname=host.get("hostname"),
|
||||
os=host.get("os"),
|
||||
services=services,
|
||||
suggested_type=suggested_type,
|
||||
status="pending",
|
||||
discovery_source=discovery_source,
|
||||
discovery_sources=[discovery_source],
|
||||
))
|
||||
if outcome == "created":
|
||||
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)
|
||||
|
||||
# nmap scan per CIDR — results stream in progressively
|
||||
@@ -683,3 +815,135 @@ async def run_scan(
|
||||
finally:
|
||||
with _cancelled_lock:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
|
||||
async def run_device_scan(
|
||||
device_id: str,
|
||||
db: AsyncSession,
|
||||
run_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
full_ports: bool = True,
|
||||
ports: str | None = None,
|
||||
) -> None:
|
||||
"""Deep-rescan one known device and refresh its inventory row.
|
||||
|
||||
No ping sweep and no mDNS: the device is already known, so the IP goes
|
||||
straight to the phase-2 port scan (``-Pn``). ``full_ports`` scans all 65535
|
||||
TCP ports, which is the point of the feature — a device added before the
|
||||
scanner knew a service, or listening on a port no curated list covers.
|
||||
Minutes, not seconds; the run is cancellable like any other.
|
||||
|
||||
``ports`` narrows that to a user-chosen spec (``80,443``, ``1-1024``) and
|
||||
wins over ``full_ports`` — the dialog prefills the full range, so a caller
|
||||
that passes something else means it.
|
||||
"""
|
||||
from app.api.routes.status import broadcast_scan_update
|
||||
|
||||
deep_scan = deep_scan or DeepScanOptions()
|
||||
port_spec = (
|
||||
ports if ports else _build_port_spec(deep_scan.http_ranges, full=full_ports)
|
||||
)
|
||||
|
||||
try:
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device is None or not device.ip:
|
||||
raise ValueError("Device has no IP to scan")
|
||||
|
||||
host: dict[str, Any] = {
|
||||
"ip": device.ip,
|
||||
"mac": device.mac,
|
||||
"hostname": device.hostname,
|
||||
"os": device.os,
|
||||
"open_ports": [],
|
||||
}
|
||||
# A rescan re-observes a device through the source it already has; it
|
||||
# is not a network discovery. Hardcoding "arp" here would tell a
|
||||
# Proxmox guest, a rack mount or a hand-added host that the network
|
||||
# scanner found it, and it would then answer that source filter.
|
||||
source = device.discovery_source or "arp"
|
||||
|
||||
# The full range goes out in slices so a stop request lands within one
|
||||
# slice instead of at the end, and so a spent budget keeps the ports
|
||||
# found so far. A curated port list is one call, as before.
|
||||
if ports:
|
||||
chunks = _port_chunks(ports)
|
||||
elif full_ports:
|
||||
chunks = _deep_port_chunks()
|
||||
else:
|
||||
chunks = [port_spec]
|
||||
# Retry-free timing pays for itself over thousands of ports on a host
|
||||
# that drops packets; over a handful it only costs accuracy.
|
||||
total_ports = sum(end - start + 1 for start, end in _parse_port_spec(port_spec))
|
||||
bounded = total_ports > _DEEP_CHUNK_SIZE
|
||||
deadline = time.monotonic() + settings.scanner_deep_host_timeout
|
||||
found: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
skipped = 0
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
if _is_cancelled(run_id):
|
||||
skipped = len(chunks) - i
|
||||
break
|
||||
if i and time.monotonic() > deadline:
|
||||
skipped = len(chunks) - i
|
||||
logger.warning(
|
||||
"[Deep scan] %s — budget of %ds spent, %d port range(s) not scanned",
|
||||
host["ip"], settings.scanner_deep_host_timeout, skipped,
|
||||
)
|
||||
break
|
||||
# bounded: retry-free timing, for a host that drops packets.
|
||||
scanned = await asyncio.to_thread(
|
||||
_nmap_scan_single, dict(host), chunk, bounded
|
||||
)
|
||||
for port in scanned.get("open_ports") or []:
|
||||
key = (port["protocol"], port["port"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
found.append(port)
|
||||
host["mac"] = host["mac"] or scanned.get("mac")
|
||||
host["os"] = scanned.get("os") or host["os"]
|
||||
|
||||
host["open_ports"] = found
|
||||
# A partial sweep still says what it saw; the row unions it in.
|
||||
partial = (
|
||||
f"Scanned {len(chunks) - skipped}/{len(chunks)} port ranges "
|
||||
f"({len(found)} open) — the rest was not reached"
|
||||
if skipped
|
||||
else None
|
||||
)
|
||||
|
||||
devices_found = 0
|
||||
if not _is_cancelled(run_id):
|
||||
# The row is being rescanned on the user's request, so it is never
|
||||
# "hidden" from itself — the route rejects hidden devices upfront.
|
||||
outcome = await process_host(
|
||||
db, host, hidden_ips=set(), deep_scan=deep_scan, discovery_source=source
|
||||
)
|
||||
if outcome == "created":
|
||||
devices_found = 1
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "cancelled" if _is_cancelled(run_id) else "done"
|
||||
run.devices_found = devices_found
|
||||
# Not a failure: a done run carrying an advisory, the way a Proxmox
|
||||
# import reports what it could not see. Never let a partial sweep
|
||||
# read as a complete one.
|
||||
if partial and run.status == "done":
|
||||
run.error = partial
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Device scan failed: %s", exc)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "error"
|
||||
run.error = str(exc)
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
finally:
|
||||
with _cancelled_lock:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
@@ -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,423 @@
|
||||
"""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
|
||||
import shutil
|
||||
|
||||
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()
|
||||
|
||||
|
||||
async def test_upgrade_keeps_each_canvas_drawing_its_own_services(db_320):
|
||||
"""3.3.3: order and visibility become the node's, and the upgrade freezes them.
|
||||
|
||||
Two canvases drew the same host with different service lists, and a scan had
|
||||
already fingerprinted a third the user put on neither. All three converge on
|
||||
one inventory row — so each node must come out of the upgrade still drawing
|
||||
what it drew, with the scanner's guess hidden on both.
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
|
||||
both = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}, ' \
|
||||
'{"port": 443, "protocol": "tcp", "service_name": "https"}]'
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{ssh}' WHERE id = 'n1'")
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{both}' WHERE id = 'n2'")
|
||||
|
||||
await database.init_db()
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
drawn = {}
|
||||
for node_id in ("n1", "n2"):
|
||||
node = await session.get(Node, node_id)
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
drawn[node_id] = [s["service_name"] for s in payload["services"] if s.get("visible", True)]
|
||||
# Seeded, not left to "show the whole row".
|
||||
assert node.display_view is not None
|
||||
assert drawn == {"n1": ["ssh"], "n2": ["ssh", "https"]}
|
||||
|
||||
# What a scan finds next lands on the row, and on no canvas.
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
device.services = [
|
||||
*device.services,
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"},
|
||||
]
|
||||
await session.commit()
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [(s["service_name"], s.get("visible", True)) for s in payload["services"]] == [
|
||||
("ssh", True), ("Uptime Kuma", False),
|
||||
]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _wind_back_to_3_3_2(engine, extra_service: dict) -> None:
|
||||
"""A database that already took the 3.3.0 upgrade, leak included.
|
||||
|
||||
Both nodes point at one row holding the union of what each drew plus what a
|
||||
scan found, and neither has a view — which is every 3.3.0-3.3.2 install.
|
||||
"""
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql("UPDATE nodes SET display_view = NULL")
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
one = await session.get(InventoryDevice, node.device_id)
|
||||
other = await session.get(InventoryDevice, (await session.get(Node, "n2")).device_id)
|
||||
for device in (one, other):
|
||||
device.services = [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 443, "protocol": "tcp", "service_name": "https"},
|
||||
extra_service,
|
||||
]
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_a_database_already_on_3_3_recovers_its_layout_from_the_backup(db_320):
|
||||
"""The second upgrade path: 3.3.0-3.3.2, where the legacy columns are gone.
|
||||
|
||||
3.3.0 unioned every canvas' services onto one row, so the row can no longer
|
||||
say who drew what — but the backup taken before that migration still can.
|
||||
Recovering from it is the difference between the user getting their canvases
|
||||
back and getting each canvas showing every other canvas' services.
|
||||
"""
|
||||
_, engine = db_320
|
||||
await _build_320(engine)
|
||||
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
|
||||
both = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}, ' \
|
||||
'{"port": 443, "protocol": "tcp", "service_name": "https"}]'
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{ssh}' WHERE id = 'n1'")
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{both}' WHERE id = 'n2'")
|
||||
|
||||
await database.init_db() # 3.2.0 -> 3.3.x, and the backup that predates it.
|
||||
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
|
||||
await _wind_back_to_3_3_2(engine, kuma)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
drawn = {}
|
||||
for node_id in ("n1", "n2"):
|
||||
node = await session.get(Node, node_id)
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
drawn[node_id] = [s["service_name"] for s in payload["services"] if s.get("visible", True)]
|
||||
assert drawn == {"n1": ["ssh"], "n2": ["ssh", "https"]}
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_property_added_while_on_3_3_survives_the_recovery(db_320):
|
||||
"""The backup is 3.2.0-era and cannot know what the user added afterwards.
|
||||
|
||||
On 3.3.0-3.3.2 the row was the only place to add a property, and every
|
||||
canvas drew it. Recovering the view strictly from the backup would hide it
|
||||
on all of them at once — the "my properties disappeared" half of #347. The
|
||||
scanner's service find is still held back: only properties are appended.
|
||||
"""
|
||||
_, engine = db_320
|
||||
await _build_320(engine)
|
||||
rack = '[{"key": "Rack", "value": "A", "icon": null, "visible": true}]'
|
||||
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
f"UPDATE nodes SET properties = '{rack}', services = '{ssh}' WHERE id = 'n1'"
|
||||
)
|
||||
|
||||
await database.init_db() # 3.2.0 -> 3.3.x, and the backup that predates it.
|
||||
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
|
||||
await _wind_back_to_3_3_2(engine, kuma)
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
# What the user re-added by hand while running 3.3.x.
|
||||
device.properties = [
|
||||
*device.properties,
|
||||
{"key": "Ports", "value": "8", "icon": None, "visible": True},
|
||||
]
|
||||
await session.commit()
|
||||
|
||||
await database.init_db()
|
||||
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [(p["key"], p.get("visible", True)) for p in payload["properties"]] == [
|
||||
("Rack", True), ("Ports", True),
|
||||
]
|
||||
# And the scan's find is still off this canvas.
|
||||
assert [s["service_name"] for s in payload["services"] if s.get("visible", True)] == ["ssh"]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_without_a_usable_backup_the_row_is_the_seed(db_320):
|
||||
"""No backup to recover from: keep showing what the canvas shows today.
|
||||
|
||||
Nothing is taken away — the user simply keeps the merged list they have been
|
||||
looking at since 3.3.0, and only what the row gains *after* this boot is
|
||||
held back.
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
await database.init_db()
|
||||
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
|
||||
await _wind_back_to_3_3_2(engine, kuma)
|
||||
for backup in db_path.parent.glob(f"{db_path.name}.back-*"):
|
||||
backup.unlink()
|
||||
|
||||
await database.init_db()
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [s["service_name"] for s in payload["services"]] == ["ssh", "https", "Uptime Kuma"]
|
||||
assert all(s.get("visible", True) for s in payload["services"])
|
||||
|
||||
# From here on the node is pinned: the next scan's find is held back.
|
||||
device.services = [*device.services, {"port": 5001, "protocol": "tcp", "service_name": "Synology DSM HTTPS"}]
|
||||
await session.commit()
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [s["service_name"] for s in payload["services"] if not s.get("visible", True)] == [
|
||||
"Synology DSM HTTPS"
|
||||
]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_the_newest_backup_that_still_has_the_columns_is_the_one_read(db_320):
|
||||
"""A 3.3.2 install has several backups; only some can answer.
|
||||
|
||||
`homelab.db.back-3.3.1` and `-3.3.2` were taken *after* the split and hold
|
||||
nothing per node; `-3.3.0` was taken before it. Reading the wrong one would
|
||||
either say nothing or resurrect a much older canvas.
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET services = "
|
||||
"'[{\"port\": 22, \"protocol\": \"tcp\", \"service_name\": \"ssh\"}]' WHERE id = 'n1'"
|
||||
)
|
||||
# Ancient: same shape, but a canvas the user has long since moved on from.
|
||||
old = db_path.parent / f"{db_path.name}.back-3.1.0"
|
||||
shutil.copy2(db_path, old)
|
||||
os.utime(old, (1, 1))
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET services = "
|
||||
"'[{\"port\": 443, \"protocol\": \"tcp\", \"service_name\": \"https\"}]' WHERE id = 'n1'"
|
||||
)
|
||||
pre_split = db_path.parent / f"{db_path.name}.back-3.3.0"
|
||||
shutil.copy2(db_path, pre_split)
|
||||
|
||||
await database.init_db() # drops the columns, and backs up under this version
|
||||
# Post-split backups: newer, and unable to say who drew what.
|
||||
for name in ("back-3.3.1", "back-3.3.2"):
|
||||
shutil.copy2(db_path, db_path.parent / f"{db_path.name}.{name}")
|
||||
|
||||
assert database._pre_split_backup() == pre_split
|
||||
assert database._views_from_backup()["n1"]["services"] == [
|
||||
{"port": 443, "protocol": "tcp", "service_name": "https"}
|
||||
]
|
||||
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
|
||||
@@ -368,3 +414,154 @@ async def test_delete_pending_refuses_a_mounted_device(client: AsyncClient, head
|
||||
assert res.status_code == 409
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == [pending_device.id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-device deep rescan (issue #350)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_requires_auth(client: AsyncClient, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/rescan")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_creates_run_for_that_ip(client: AsyncClient, headers, pending_device):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
data = res.json()
|
||||
assert data["status"] == "running"
|
||||
assert data["kind"] == "device"
|
||||
# One host, not the configured ranges — the scan targets this device only.
|
||||
assert data["ranges"] == ["192.168.1.100/32"]
|
||||
bg.assert_called_once()
|
||||
assert bg.call_args.args[1] == pending_device.id
|
||||
# Full range unless the caller says otherwise.
|
||||
assert bg.call_args.args[3] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_passes_the_requested_port_range(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
"""The dialog's range reaches the scanner verbatim."""
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": " 80,443,8000-9000 "},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert bg.call_args.args[4] == "80,443,8000-9000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_rejects_an_unusable_port_range(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
"""A bad spec is refused here, not handed to nmap."""
|
||||
for bad in ["0", "65536", "100-50", "80,", "http"]:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": bad},
|
||||
)
|
||||
assert res.status_code == 422, f"{bad}: {res.text}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_treats_a_blank_range_as_the_full_sweep(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": " "},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert bg.call_args.args[4] is None
|
||||
assert bg.call_args.args[3] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_unknown_id_404(client: AsyncClient, headers):
|
||||
res = await client.post(f"/api/v1/scan/pending/{uuid.uuid4()}/rescan", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_without_ip_409(client: AsyncClient, headers, db_session):
|
||||
device = InventoryDevice(id=str(uuid.uuid4()), ip=None, hostname="zigbee-lamp", status="pending")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/pending/{device.id}/rescan", headers=headers)
|
||||
assert res.status_code == 409
|
||||
assert "IP" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_hidden_409(client: AsyncClient, headers, db_session):
|
||||
device = InventoryDevice(id=str(uuid.uuid4()), ip="192.168.1.77", status="hidden")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/pending/{device.id}/rescan", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_serialized_per_device(client: AsyncClient, headers, pending_device):
|
||||
"""A second rescan while the first still runs is refused, not duplicated."""
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
first = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
second = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 409
|
||||
assert "already running" in second.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_allows_a_new_run_once_the_first_finished(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
first = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert first.status_code == 200
|
||||
run_id = first.json()["id"]
|
||||
stopped = await client.post(f"/api/v1/scan/{run_id}/stop", headers=headers)
|
||||
assert stopped.status_code == 200
|
||||
again = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert again.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_returns_status(client: AsyncClient, headers, pending_device):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
started = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
run_id = started.json()["id"]
|
||||
res = await client.get(f"/api/v1/scan/runs/{run_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["id"] == run_id
|
||||
assert res.json()["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_unknown_id_404(client: AsyncClient, headers):
|
||||
res = await client.get(f"/api/v1/scan/runs/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
@@ -12,9 +12,14 @@ from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
||||
session rollback path must execute without a follow-on exception."""
|
||||
async def test_background_scan_marks_run_errored_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → error and the
|
||||
session rollback path must execute without a follow-on exception.
|
||||
|
||||
"error" and not "failed": one condition, one name — it is what run_scan
|
||||
writes when it catches the failure itself, and the only one Scan History
|
||||
filters and colours.
|
||||
"""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
@@ -36,13 +41,13 @@ async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "failed"
|
||||
assert refreshed.status == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
||||
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
||||
must NOT overwrite that terminal status with 'failed'."""
|
||||
must NOT overwrite that terminal status with 'error'."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
@@ -131,16 +136,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 +146,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 +178,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 +208,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
|
||||
@@ -429,3 +441,30 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_device_scan_marks_run_errored_on_exception(mem_db):
|
||||
"""Same word as the network scan — a device run that blows up reads alike."""
|
||||
from app.api.routes.scan import _background_device_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", kind="device", ranges=["10.0.0.5/32"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_device_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_device_scan(run_id, "d1")
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "error"
|
||||
|
||||
@@ -336,6 +336,80 @@ async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: Asy
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_oidc_mutation_allows_app_origin_missing_from_cors_origins(
|
||||
client: AsyncClient, oidc_settings
|
||||
):
|
||||
"""Regression for #356: a same-origin reverse-proxy deploy needs no CORS, so
|
||||
CORS_ORIGINS is left at its localhost default while the browser still sends
|
||||
Origin — every POST used to 403."""
|
||||
oidc_settings.cors_origins = ["http://localhost:5173", "http://localhost:3000"]
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
await client.get("/api/v1/auth/oidc/callback")
|
||||
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "From OIDC", "design_type": "network"},
|
||||
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
# The fallback widens the allowlist by exactly one origin, nothing more.
|
||||
evil = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "Nope", "design_type": "network"},
|
||||
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert evil.status_code == 403
|
||||
|
||||
|
||||
def test_origin_is_allowed_falls_back_to_the_oidc_redirect_origin(oidc_settings):
|
||||
from app.core.security import origin_is_allowed
|
||||
|
||||
oidc_settings.cors_origins = ["http://localhost:3000"]
|
||||
oidc_settings.oidc_redirect_uri = "https://homelable.example.com/api/v1/auth/oidc/callback"
|
||||
|
||||
assert origin_is_allowed("https://homelable.example.com") is True
|
||||
assert origin_is_allowed("https://homelable.example.com/") is True
|
||||
assert origin_is_allowed("http://localhost:3000") is True
|
||||
assert origin_is_allowed("https://evil.example") is False
|
||||
assert origin_is_allowed(None) is False
|
||||
|
||||
# A redirect URI that is not an absolute URL contributes no origin.
|
||||
oidc_settings.oidc_redirect_uri = "/api/v1/auth/oidc/callback"
|
||||
assert origin_is_allowed("https://homelable.example.com") is False
|
||||
|
||||
|
||||
def test_oidc_settings_warn_when_cors_origins_omits_the_app_origin(caplog):
|
||||
from app.core.config import Settings
|
||||
|
||||
base = {
|
||||
"secret_key": "x" * 32,
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "secret",
|
||||
"oidc_redirect_uri": "https://homelable.example.com/api/v1/auth/oidc/callback",
|
||||
}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
Settings(**base, cors_origins=["http://localhost:3000"])
|
||||
assert "https://homelable.example.com" in caplog.text
|
||||
assert "CORS_ORIGINS" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
Settings(**base, cors_origins=["https://homelable.example.com/"])
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/auth/logout", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
@@ -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"}])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
+482
-20
@@ -375,6 +375,79 @@ def test_nmap_scan_single_non_root_uses_connect_scan():
|
||||
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
||||
|
||||
|
||||
def test_nmap_scan_single_discovery_is_unbounded_by_default():
|
||||
"""The range scan's discovery pass keeps its authoritative, untimed run."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.14", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.14", {})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=1000):
|
||||
_nmap_scan_single(host)
|
||||
|
||||
args = disc.scan.call_args.kwargs["arguments"]
|
||||
assert "--host-timeout" not in args
|
||||
assert "--min-rate" not in args
|
||||
|
||||
|
||||
def test_nmap_scan_single_bounded_never_sets_a_host_timeout():
|
||||
"""A deep slice drops retries — never --host-timeout.
|
||||
|
||||
nmap answers a host timeout with "Skipping host <ip> due to host timeout"
|
||||
and throws away every port it had already found, so a ceiling here turns a
|
||||
slow scan into one that reports nothing. The time on a dropping host goes to
|
||||
the retry pass — measured 2x — so that is what the deep scan gives up.
|
||||
"""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.15", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.15", {})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=1000):
|
||||
_nmap_scan_single(host, "1-8192", True)
|
||||
|
||||
args = disc.scan.call_args.kwargs["arguments"]
|
||||
assert "-p 1-8192" in args
|
||||
assert "--host-timeout" not in args
|
||||
assert "--max-retries 0" in args
|
||||
|
||||
|
||||
def test_deep_port_chunks_cover_every_port_once():
|
||||
from app.services.scanner import _deep_port_chunks
|
||||
|
||||
chunks = _deep_port_chunks(8192)
|
||||
assert chunks[0] == "1-8192"
|
||||
assert chunks[-1].endswith("-65535")
|
||||
covered = []
|
||||
for c in chunks:
|
||||
lo, hi = (int(x) for x in c.split("-"))
|
||||
covered.extend(range(lo, hi + 1))
|
||||
assert covered == list(range(1, 65536))
|
||||
|
||||
|
||||
def test_port_chunks_pack_ranges_and_honour_the_slice_size():
|
||||
from app.services.scanner import _port_chunks
|
||||
|
||||
# Small ranges share one call instead of one call each.
|
||||
assert _port_chunks("80,443,8000-9000") == ["80,443,8000-9000"]
|
||||
# A range wider than the slice is cut at the slice boundary.
|
||||
assert _port_chunks("1-100", 40) == ["1-40", "41-80", "81-100"]
|
||||
# Overlapping input is merged before slicing, so no port is scanned twice.
|
||||
assert _port_chunks("1-100,50-200", 1000) == ["1-200"]
|
||||
assert _port_chunks("nonsense") == []
|
||||
|
||||
|
||||
def test_parse_port_spec_rejects_what_nmap_could_not_use():
|
||||
from app.services.scanner import _parse_port_spec, _valid_port_spec
|
||||
|
||||
for bad in ["", " ", "0", "65536", "100-50", "80,", "http", "-80"]:
|
||||
assert _parse_port_spec(bad) == [], bad
|
||||
assert _valid_port_spec(bad) is False, bad
|
||||
assert _valid_port_spec("80,443,8000-9000") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_scan
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -596,13 +669,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 +694,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 +723,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 +752,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
|
||||
@@ -737,6 +823,48 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
||||
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_keeps_services_the_fingerprint_cannot_see(mem_db):
|
||||
"""A re-scan unions its fingerprint onto the row — it never replaces it.
|
||||
|
||||
Since 3.3.0 the row is the only copy of a device's services and every canvas
|
||||
drawing it reads that list, so overwriting it with what nmap happened to
|
||||
match would delete hand-added services everywhere at once (#347).
|
||||
"""
|
||||
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(InventoryDevice(
|
||||
id="row-1", ip="192.168.1.60", status="approved",
|
||||
discovery_source="arp", discovery_sources=["arp"],
|
||||
services=[
|
||||
{"port": 9000, "protocol": "tcp", "service_name": "Portainer", "path": "/#!/home"},
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
],
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.60", "hostname": "docker.lan", "mac": None, "os": None,
|
||||
"open_ports": [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]}]
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
row = await session.get(InventoryDevice, "row-1")
|
||||
|
||||
by_port = {s["port"]: s for s in row.services}
|
||||
assert by_port[9000]["service_name"] == "Portainer" # hand-added, untouched
|
||||
assert by_port[9000]["path"] == "/#!/home"
|
||||
assert 22 in by_port # what the scan saw is still there
|
||||
assert row.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
|
||||
@@ -771,11 +899,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": []}]
|
||||
@@ -974,3 +1103,336 @@ async def test_run_scan_no_probe_when_disabled(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_device_scan — per-device deep rescan (issue #350)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_port_spec_full_covers_every_tcp_port():
|
||||
from app.services.scanner import _build_port_spec
|
||||
|
||||
# full wins over the curated list *and* over user ranges — the deep rescan
|
||||
# is only worth its minutes if it really scans everything.
|
||||
assert _build_port_spec(None, full=True) == "1-65535"
|
||||
assert _build_port_spec(["8000-8100"], full=True) == "1-65535"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_refreshes_services_and_marks_run_done(mem_db):
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="approved", services=[]))
|
||||
await session.commit()
|
||||
|
||||
seen_specs = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
seen_specs.append(port_spec)
|
||||
# A full-range rescan is always bounded, or it never ends.
|
||||
assert bounded is True
|
||||
# Only the slice holding 22 reports it — the union is the caller's job.
|
||||
if port_spec == "1-8192":
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert device is not None and run is not None
|
||||
assert run.status == "done"
|
||||
assert device.last_scan is not None
|
||||
assert any(s.get("port") == 22 for s in device.services)
|
||||
# A rescan refreshes an existing row; it never spawns a second one.
|
||||
assert device.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_hand_added_services(mem_db):
|
||||
"""Regression: the rescan unions, it does not replace.
|
||||
|
||||
Services the user typed in by hand are the only copy that exists — every
|
||||
canvas drawing the device reads this list.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
hand_added = {"port": 9000, "protocol": "tcp", "service_name": "My App"}
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[hand_added]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
ports = {s.get("port") for s in device.services}
|
||||
assert ports == {22, 9000}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_keeps_the_icon_the_user_picked_for_a_service(mem_db):
|
||||
"""Regression: every scan used to repaint hand-picked service icons.
|
||||
|
||||
The fingerprint guesses an icon from the port; the user's choice is the
|
||||
only one that means anything, so a rescan leaves it where it is.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
curated = {
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"service_name": "ssh",
|
||||
"icon": "brand:openssh",
|
||||
"category": "remote",
|
||||
}
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="approved", services=[curated]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
matches = [s for s in device.services if s.get("port") == 22]
|
||||
# Merged in place — the name key is case-insensitive, so "SSH" from the
|
||||
# signature does not append a second row next to the user's "ssh".
|
||||
assert len(matches) == 1
|
||||
ssh = matches[0]
|
||||
assert ssh["icon"] == "brand:openssh"
|
||||
assert ssh["category"] == "remote"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_the_device_own_discovery_source(mem_db):
|
||||
"""A rescan re-observes a device; it does not discover it on the network.
|
||||
|
||||
Tagging every rescanned device "arp" told a Proxmox guest or a hand-added
|
||||
host that the network scanner found it, and it then answered that filter.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(
|
||||
id="d1",
|
||||
ip="192.168.1.9",
|
||||
status="approved",
|
||||
services=[],
|
||||
discovery_source="proxmox",
|
||||
discovery_sources=["proxmox"],
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 8006, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id, ports="8006")
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
assert device.discovery_sources == ["proxmox"]
|
||||
assert device.discovery_source == "proxmox"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_marks_run_error_when_device_has_no_ip(mem_db):
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip=None, status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert run is not None
|
||||
assert run.status == "error"
|
||||
assert run.error is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_skips_nmap_when_cancelled(mem_db):
|
||||
from app.services.scanner import _cancelled_runs, request_cancel, run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
request_cancel(run_id)
|
||||
single = MagicMock()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", new=single), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
single.assert_not_called()
|
||||
assert run is not None
|
||||
assert run.status == "cancelled"
|
||||
# The run cleans up its cancellation flag on the way out.
|
||||
assert run_id not in _cancelled_runs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_unions_ports_across_slices(mem_db):
|
||||
"""Every slice contributes; a slow one costs only its own ports."""
|
||||
from app.services.scanner import _deep_port_chunks, run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
lo = int(port_spec.split("-")[0])
|
||||
if lo == 1:
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": ""}]
|
||||
elif lo == 8193:
|
||||
host_dict["open_ports"] = [{"port": 8096, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {22, 8096}
|
||||
# A complete sweep carries no advisory.
|
||||
assert run.error is None
|
||||
assert len(_deep_port_chunks()) == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_honours_a_requested_port_range(mem_db):
|
||||
"""A user-chosen range replaces the full sweep — and skips retry-free timing.
|
||||
|
||||
A handful of ports is cheap enough to scan properly; the bounded flags only
|
||||
pay off over thousands of them.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
calls = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
calls.append((port_spec, bounded))
|
||||
host_dict["open_ports"] = [{"port": 8096, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id, ports="8000-9000")
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert calls == [("8000-9000", False)]
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {8096}
|
||||
assert run.status == "done"
|
||||
assert run.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_what_it_found_when_the_budget_runs_out(mem_db):
|
||||
"""A spent budget stops the sweep — it never discards the ports found.
|
||||
|
||||
The earlier --host-timeout did exactly that (nmap skips the host wholesale),
|
||||
which is why a deep scan could come back empty on a slow host.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
calls = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
calls.append(port_spec)
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
# Budget already spent when the first slice returns.
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.services.scanner.settings") as mock_settings, \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
mock_settings.scanner_deep_host_timeout = -1
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert calls == ["1-8192"]
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {22}
|
||||
assert run.status == "done"
|
||||
# Partial coverage is reported, never passed off as a full sweep.
|
||||
assert run.error is not None
|
||||
assert "1/8" in run.error
|
||||
|
||||
@@ -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.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.5",
|
||||
"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.5",
|
||||
"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',
|
||||
@@ -108,6 +109,20 @@ export interface DeepScanConfig {
|
||||
|
||||
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
|
||||
|
||||
/** A row of `scan_runs` — what `/scan/runs` and the device rescan return. */
|
||||
export interface ScanRunSummary {
|
||||
id: string
|
||||
// 'failed' is legacy: runs recorded before the backend settled on 'error'
|
||||
// for the same condition. Still read, never written.
|
||||
status: 'running' | 'done' | 'cancelled' | 'error' | 'failed'
|
||||
kind: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// A device the backend refused to place because an equivalent node already
|
||||
// exists on the target design (same ip/mac/ieee). `existing_node_id` points at
|
||||
// the node already there so the UI can link to it.
|
||||
@@ -150,8 +165,24 @@ 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),
|
||||
/**
|
||||
* Deep-rescan one known device: every TCP port, then re-fingerprint. Answers
|
||||
* "this device predates the scanner knowing that service" (issue #350).
|
||||
* Returns the ScanRun, so the caller polls `run` and can `stop` it.
|
||||
* 409 when the device has no IP, is hidden, or is already being rescanned.
|
||||
*/
|
||||
rescanDevice: (id: string, opts?: { full_ports?: boolean; ports?: string; http_probe_enabled?: boolean; verify_tls?: boolean }) =>
|
||||
api.post<ScanRunSummary>(`/scan/pending/${id}/rescan`, opts ?? {}),
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
run: (runId: string) => api.get<ScanRunSummary>(`/scan/runs/${runId}`),
|
||||
clearPending: () => api.delete('/scan/pending'),
|
||||
/** Remove one inventory entry. 409 when a rack still mounts it. */
|
||||
deletePending: (id: string) => api.delete<{ deleted: boolean }>(`/scan/pending/${id}`),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -40,7 +40,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
const statusColor = theme.colors.statusColors[data.status]
|
||||
const isOnline = data.status === 'online'
|
||||
const services = data.services ?? []
|
||||
// Hiding a service is a property of this node, not of the device: another
|
||||
// canvas drawing the same device answers for itself.
|
||||
const services = (data.services ?? []).filter((svc) => svc.visible !== false)
|
||||
const showServices = data.custom_colors?.show_services === true
|
||||
const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname
|
||||
|
||||
@@ -144,11 +146,14 @@ 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">
|
||||
{visibleProperties.map((prop) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
const hasValue = Boolean(prop.value.trim())
|
||||
return (
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
<span className={hasValue ? 'truncate max-w-15 shrink-0' : 'truncate min-w-0'} title={prop.key}>{prop.key}</span>
|
||||
{hasValue && (
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -162,7 +167,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'
|
||||
|
||||
@@ -107,6 +107,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
{/* Properties */}
|
||||
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
const hasValue = Boolean(prop.value.trim())
|
||||
return (
|
||||
<div
|
||||
key={prop.key}
|
||||
@@ -119,8 +120,10 @@ 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>
|
||||
<span className={hasValue ? 'truncate max-w-15 shrink-0' : 'truncate min-w-0'} title={prop.key}>{prop.key}</span>
|
||||
{hasValue && (
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* A property with no value gives its whole line to the label.
|
||||
*
|
||||
* The label is capped at max-w-15 so a long key cannot crowd out the value
|
||||
* beside it. With no value there is nothing to protect, and keeping the cap
|
||||
* truncated the label against empty space (issue #361).
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { Server } from 'lucide-react'
|
||||
import { BaseNode } from '../BaseNode'
|
||||
import type { NodeData, NodeProperty } from '@/types'
|
||||
|
||||
vi.mock('@/stores/canvasStore', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/stores/canvasStore')>('@/stores/canvasStore')
|
||||
return {
|
||||
...actual,
|
||||
useCanvasStore: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ hideIp: false, serviceStatuses: {} }),
|
||||
}
|
||||
})
|
||||
|
||||
function renderNode(properties: NodeProperty[]) {
|
||||
const data: NodeData = { label: 'NAS', type: 'nas', status: 'online', properties }
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<BaseNode
|
||||
{...({ id: 'n1', data, selected: false, icon: Server } as React.ComponentProps<typeof BaseNode>)}
|
||||
/>
|
||||
</ReactFlowProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function labelOf(container: HTMLElement, key: string) {
|
||||
return [...container.querySelectorAll('span')].find((s) => s.textContent === key)
|
||||
}
|
||||
|
||||
const prop = (key: string, value: string): NodeProperty => ({ key, value, visible: true })
|
||||
|
||||
describe('BaseNode properties', () => {
|
||||
it('drops the label width cap when the value is empty', () => {
|
||||
const { container } = renderNode([prop('A very long property label', '')])
|
||||
const label = labelOf(container, 'A very long property label')
|
||||
expect(label).toBeDefined()
|
||||
expect(label!.className).not.toContain('max-w-15')
|
||||
expect(label!.className).toContain('min-w-0')
|
||||
})
|
||||
|
||||
it('treats a whitespace-only value as empty', () => {
|
||||
const { container } = renderNode([prop('Label', ' ')])
|
||||
const label = labelOf(container, 'Label')
|
||||
expect(label!.className).not.toContain('max-w-15')
|
||||
})
|
||||
|
||||
it('keeps the cap when a value shares the line', () => {
|
||||
const { container } = renderNode([prop('Label', '42')])
|
||||
const label = labelOf(container, 'Label')
|
||||
expect(label!.className).toContain('max-w-15')
|
||||
expect(container.textContent).toContain('· 42')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* A service hidden on this node stays on the device.
|
||||
*
|
||||
* Order and visibility are the node's, the facts are the inventory row's, so
|
||||
* the same device drawn on two canvases can show two different service lists.
|
||||
* The node card only draws what its own copy says is visible.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { Server } from 'lucide-react'
|
||||
import { BaseNode } from '../BaseNode'
|
||||
import type { NodeData, ServiceInfo } from '@/types'
|
||||
|
||||
vi.mock('@/stores/canvasStore', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/stores/canvasStore')>('@/stores/canvasStore')
|
||||
return {
|
||||
...actual,
|
||||
useCanvasStore: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ hideIp: false, serviceStatuses: {} }),
|
||||
}
|
||||
})
|
||||
|
||||
const ssh: ServiceInfo = { port: 22, protocol: 'tcp', service_name: 'ssh' }
|
||||
const kuma: ServiceInfo = { port: 3001, protocol: 'tcp', service_name: 'Uptime Kuma' }
|
||||
|
||||
function renderNode(services: ServiceInfo[]) {
|
||||
const data: NodeData = {
|
||||
label: 'NAS',
|
||||
type: 'nas',
|
||||
status: 'online',
|
||||
services,
|
||||
// Services only render when the node is set to show them.
|
||||
custom_colors: { show_services: true },
|
||||
}
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<BaseNode
|
||||
{...({ id: 'n1', data, selected: false, icon: Server } as React.ComponentProps<typeof BaseNode>)}
|
||||
/>
|
||||
</ReactFlowProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BaseNode services', () => {
|
||||
it('draws a service that carries no visible flag', () => {
|
||||
renderNode([ssh, kuma])
|
||||
expect(screen.getByText('ssh')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime Kuma')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves out one this node hid, keeping the rest', () => {
|
||||
renderNode([ssh, { ...kuma, visible: false }])
|
||||
expect(screen.getByText('ssh')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Uptime Kuma')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('draws them in the order the node carries', () => {
|
||||
const { container } = renderNode([kuma, ssh])
|
||||
const names = [...container.querySelectorAll('span.font-medium')].map((n) => n.textContent)
|
||||
expect(names.filter((n) => n === 'ssh' || n === 'Uptime Kuma')).toEqual(['Uptime Kuma', 'ssh'])
|
||||
})
|
||||
})
|
||||
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Deep scan — pick what to sweep before starting one.
|
||||
*
|
||||
* The full 65535-port range is the default and the point of the feature
|
||||
* (issue #350), but it costs minutes per host. A user who already knows where
|
||||
* a service lives, or who only wants the low ports back, types a narrower spec
|
||||
* here instead of waiting for the whole range.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Radar } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { countPorts, isValidPortSpec, FULL_PORT_RANGE } from '@/utils/portSpec'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
|
||||
const PRESETS: Array<{ label: string; spec: string }> = [
|
||||
{ label: 'All ports', spec: FULL_PORT_RANGE },
|
||||
{ label: 'Well-known', spec: '1-1024' },
|
||||
{ label: 'Common', spec: '1-10000' },
|
||||
]
|
||||
|
||||
interface DeepScanModalProps {
|
||||
open: boolean
|
||||
target?: string | null
|
||||
onClose: () => void
|
||||
onStart: (ports: string) => void
|
||||
}
|
||||
|
||||
export function DeepScanModal({ open, target, onClose, onStart }: DeepScanModalProps) {
|
||||
// Prefilled with the full range. The caller mounts this only while it is
|
||||
// open, so a previous narrow spec never becomes the next scan's default.
|
||||
const [spec, setSpec] = useState(FULL_PORT_RANGE)
|
||||
|
||||
const trimmed = spec.trim()
|
||||
const valid = isValidPortSpec(trimmed)
|
||||
const total = countPorts(trimmed)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!valid) return
|
||||
onStart(trimmed)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md bg-[#161b22] border-[#30363d]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Radar size={15} className="text-[#00d4ff]" />
|
||||
Deep scan{target ? ` — ${target}` : ''}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="deep-scan-ports" className="text-xs text-muted-foreground">
|
||||
Port range
|
||||
</Label>
|
||||
<Input
|
||||
id="deep-scan-ports"
|
||||
value={spec}
|
||||
onChange={(e) => setSpec(e.target.value)}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={FULL_PORT_RANGE}
|
||||
data-testid="deep-scan-ports"
|
||||
className="h-8 font-mono text-sm bg-[#21262d] border-[#30363d]"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
A port, a range, or a comma list — <span className="font-mono">80,443,8000-9000</span>
|
||||
</span>
|
||||
{trimmed !== '' && (
|
||||
<span className={valid ? 'text-muted-foreground shrink-0' : 'text-[#f85149] shrink-0'}>
|
||||
{valid ? `${total.toLocaleString('en-US')} ports` : 'Invalid range'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.spec}
|
||||
type="button"
|
||||
onClick={() => setSpec(preset.spec)}
|
||||
className={`px-2 py-1 text-[10px] border transition-colors cursor-pointer ${modalStyles['modal-radius']} ${
|
||||
trimmed === preset.spec
|
||||
? 'border-[#00d4ff] text-[#00d4ff] bg-[#00d4ff]/10'
|
||||
: 'border-[#30363d] text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Scanning runs in the background — the whole range takes several minutes. Found services are
|
||||
merged into the device; nothing already recorded is removed.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!valid}
|
||||
data-testid="deep-scan-start"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Start scan
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Per-device deep rescan (issue #350) — the detail modal starts a full-port
|
||||
* scan of one device, waits on the run, and folds the fresh services back in.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { InventoryDeviceModal } from '../InventoryDeviceModal'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
const mockRescanDevice = vi.fn()
|
||||
const mockRun = vi.fn()
|
||||
const mockPending = vi.fn()
|
||||
const mockStop = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
updatePending: vi.fn(),
|
||||
rescanDevice: (...a: unknown[]) => mockRescanDevice(...a),
|
||||
run: (...a: unknown[]) => mockRun(...a),
|
||||
pending: (...a: unknown[]) => mockPending(...a),
|
||||
stop: (...a: unknown[]) => mockStop(...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,
|
||||
}
|
||||
}
|
||||
|
||||
/** The link opens the port-range dialog; the scan starts from there. */
|
||||
async function startScan() {
|
||||
fireEvent.click(screen.getByTestId('device-rescan'))
|
||||
await waitFor(() => expect(screen.getByTestId('deep-scan-start')).toBeInTheDocument())
|
||||
// The start resolves a promise that sets state — act() keeps that update
|
||||
// inside the test's control, fake timers or not.
|
||||
await act(async () => { fireEvent.click(screen.getByTestId('deep-scan-start')) })
|
||||
}
|
||||
|
||||
const noop = { onClose: vi.fn(), onApprove: vi.fn(), onHide: vi.fn(), onIgnore: vi.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRescanDevice.mockResolvedValue({ data: { id: 'run-1', status: 'running' } })
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'running', error: null } })
|
||||
mockPending.mockResolvedValue({ data: [] })
|
||||
mockStop.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — deep rescan', () => {
|
||||
it('offers the deep scan only when the device has an IP', () => {
|
||||
const { rerender } = render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
expect(screen.getByTestId('device-rescan')).toBeInTheDocument()
|
||||
|
||||
rerender(<InventoryDeviceModal {...noop} device={makeDevice({ id: 'dev-2', ip: null })} />)
|
||||
expect(screen.queryByTestId('device-rescan')).toBeNull()
|
||||
})
|
||||
|
||||
it('is absent for a Zigbee device — it has no IP services at all', () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({ discovery_source: 'zigbee', ieee_address: '0x00124b' })}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('device-rescan')).toBeNull()
|
||||
})
|
||||
|
||||
it('scans every port and swaps to a stop control while it runs', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
expect(mockRescanDevice).toHaveBeenCalledWith('dev-1', { ports: '1-65535' })
|
||||
expect(screen.queryByTestId('device-rescan')).toBeNull()
|
||||
})
|
||||
|
||||
it('sends the range the user typed instead of the whole space', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByTestId('device-rescan'))
|
||||
const input = await screen.findByTestId('deep-scan-ports')
|
||||
expect(input).toHaveValue('1-65535')
|
||||
|
||||
fireEvent.change(input, { target: { value: '80,443,8000-9000' } })
|
||||
fireEvent.click(screen.getByTestId('deep-scan-start'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockRescanDevice).toHaveBeenCalledWith('dev-1', { ports: '80,443,8000-9000' })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to start on a range nmap could not use', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByTestId('device-rescan'))
|
||||
const input = await screen.findByTestId('deep-scan-ports')
|
||||
|
||||
fireEvent.change(input, { target: { value: '99999' } })
|
||||
expect(screen.getByTestId('deep-scan-start')).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByTestId('deep-scan-start'))
|
||||
expect(mockRescanDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops the run through the same ScanRun the header uses', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('device-rescan-stop'))
|
||||
await waitFor(() => expect(mockStop).toHaveBeenCalledWith('run-1'))
|
||||
})
|
||||
|
||||
it('keeps the button enabled again when the start is refused', async () => {
|
||||
mockRescanDevice.mockRejectedValue({
|
||||
response: { data: { detail: 'A scan is already running for this device' } },
|
||||
})
|
||||
const { toast } = await import('sonner')
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith('A scan is already running for this device')
|
||||
)
|
||||
expect(screen.getByTestId('device-rescan')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('folds the freshly found services back into the open modal', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const onSaved = vi.fn()
|
||||
const fresh = makeDevice({
|
||||
services: [{ port: 8096, protocol: 'tcp', service_name: 'Jellyfin' }],
|
||||
})
|
||||
render(<InventoryDeviceModal {...noop} onSaved={onSaved} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'done', error: null } })
|
||||
mockPending.mockResolvedValue({ data: [fresh] })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(fresh))
|
||||
// Polling ends with the run — no second request once it is done.
|
||||
const calls = mockRun.mock.calls.length
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(6000) })
|
||||
expect(mockRun.mock.calls.length).toBe(calls)
|
||||
})
|
||||
|
||||
it('keeps an edit in progress when the scan lands', async () => {
|
||||
// The parent patches the row in place on onSaved (`setSelected(saved)`),
|
||||
// handing down a new object for the same device. That must not throw away
|
||||
// a form the user is still filling in — a deep scan runs for minutes.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const onSaved = vi.fn()
|
||||
const device = makeDevice()
|
||||
const fresh = makeDevice({
|
||||
services: [{ port: 8096, protocol: 'tcp', service_name: 'Jellyfin' }],
|
||||
})
|
||||
const { rerender } = render(
|
||||
<InventoryDeviceModal {...noop} onSaved={onSaved} device={device} />
|
||||
)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Display name'), { target: { value: 'Media box' } })
|
||||
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'done', error: null } })
|
||||
mockPending.mockResolvedValue({ data: [fresh] })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
// The fresh row still reaches the canvas and the grid — the scan is not
|
||||
// discarded just because a form is open.
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(fresh))
|
||||
rerender(<InventoryDeviceModal {...noop} onSaved={onSaved} device={fresh} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Media box')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still resets the form when pointed at another device', async () => {
|
||||
const { rerender } = render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Display name'), { target: { value: 'Media box' } })
|
||||
|
||||
rerender(<InventoryDeviceModal {...noop} device={makeDevice({ id: 'dev-2' })} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
|
||||
expect(screen.queryByDisplayValue('Media box')).toBeNull()
|
||||
})
|
||||
|
||||
it('says partial when the sweep ran out of budget', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const { toast } = await import('sonner')
|
||||
const fresh = makeDevice({ services: [{ port: 22, protocol: 'tcp', service_name: 'SSH' }] })
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
// A done run carrying an advisory — it scanned part of the range only.
|
||||
mockRun.mockResolvedValue({
|
||||
data: { id: 'run-1', status: 'done', error: 'Scanned 3/8 port ranges (1 open) — the rest was not reached' },
|
||||
})
|
||||
mockPending.mockResolvedValue({ data: [fresh] })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Scan partial — 1 service')
|
||||
)
|
||||
)
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a failed run instead of silently ending', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const { toast } = await import('sonner')
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'error', error: 'nmap missing' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Scan failed: nmap missing'))
|
||||
expect(mockPending).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -129,6 +134,16 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
if (openSvcModal?.index === index) setSvcModal(null)
|
||||
}
|
||||
|
||||
// Per node, not per device: the same device drawn on two canvases can show a
|
||||
// service on one and hide it on the other. Only the order and the flag are the
|
||||
// node's — the service itself still belongs to the inventory row.
|
||||
const handleToggleService = (index: number) => {
|
||||
snapshotHistory()
|
||||
updateNode(node.id, {
|
||||
services: services.map((svc, i) => (i === index ? { ...svc, visible: svc.visible === false } : svc)),
|
||||
})
|
||||
}
|
||||
|
||||
const handleStartEdit = (index: number) => {
|
||||
const svc = services[index]
|
||||
if (!svc) return
|
||||
@@ -207,6 +222,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 +258,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}
|
||||
@@ -245,6 +270,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
setDragSvcIndex(null)
|
||||
setDragOverSvcIndex(null)
|
||||
}}
|
||||
onToggleVisible={() => handleToggleService(i)}
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
@@ -579,7 +605,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
|
||||
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
|
||||
svc: ServiceInfo
|
||||
host?: string
|
||||
status?: ServiceStatus
|
||||
@@ -590,6 +616,7 @@ function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, on
|
||||
onDragEnter: () => void
|
||||
onDragEnd: () => void
|
||||
onDrop: () => void
|
||||
onToggleVisible: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
@@ -600,6 +627,8 @@ function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, on
|
||||
// A live offline service overrides the category colour with red.
|
||||
const color = status === 'offline' ? '#f85149' : categoryColor
|
||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||
// Absent means shown: a service only carries the flag once it has been hidden.
|
||||
const shown = svc.visible !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -678,6 +707,14 @@ function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, on
|
||||
<span className="w-2.5" />
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggleVisible() }}
|
||||
className="text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer transition-colors"
|
||||
title={shown ? 'Hide on node' : 'Show on node'}
|
||||
>
|
||||
{shown ? <Eye size={10} /> : <EyeOff size={10} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer"
|
||||
|
||||
@@ -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' }
|
||||
@@ -506,6 +546,64 @@ describe('DetailPanel', () => {
|
||||
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('hides a service on this node without touching the service itself', () => {
|
||||
// Visibility is the node's answer, not the device's: the rest of the
|
||||
// service record goes back unchanged so other canvases keep drawing it.
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||
((sel?: (s: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
nodes: [makeNode({ services: [{ port: 3001, protocol: 'tcp' as const, service_name: 'Uptime Kuma' }] })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setNodeSize: vi.fn(),
|
||||
serviceStatuses: {},
|
||||
}
|
||||
return sel ? sel(state) : state
|
||||
}) as unknown as typeof canvasStore.useCanvasStore,
|
||||
)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Hide on node'))
|
||||
expect(updateNode.mock.calls[0][1].services).toEqual([
|
||||
{ port: 3001, protocol: 'tcp', service_name: 'Uptime Kuma', visible: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows a hidden service again', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||
((sel?: (s: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
nodes: [makeNode({
|
||||
services: [{ port: 3001, protocol: 'tcp' as const, service_name: 'Uptime Kuma', visible: false }],
|
||||
})],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setNodeSize: vi.fn(),
|
||||
serviceStatuses: {},
|
||||
}
|
||||
return sel ? sel(state) : state
|
||||
}) as unknown as typeof canvasStore.useCanvasStore,
|
||||
)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Show on node'))
|
||||
expect(updateNode.mock.calls[0][1].services[0].visible).toBe(true)
|
||||
})
|
||||
|
||||
it('is not draggable with a single service', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -744,7 +842,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 +851,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,144 @@
|
||||
/**
|
||||
* 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'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvasStore — applyDeviceFacts keeps each node\'s arrangement', () => {
|
||||
beforeEach(resetStore)
|
||||
|
||||
const ssh = { port: 22, protocol: 'tcp' as const, service_name: 'ssh' }
|
||||
const kuma = { port: 3001, protocol: 'tcp' as const, service_name: 'Uptime Kuma' }
|
||||
|
||||
const drawnAs = (id: string, services: typeof ssh[]) =>
|
||||
makeNode(id, makeNodeData({ device_id: 'd-1', label: 'NAS', services }))
|
||||
|
||||
it('does not unhide a service the node hid', () => {
|
||||
useCanvasStore.getState().loadCanvas([drawnAs('n1', [ssh, { ...kuma, visible: false }])], [])
|
||||
// The inventory sends the row's own copy, which carries no such flag.
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { services: [ssh, kuma] })
|
||||
expect(useCanvasStore.getState().nodes[0].data.services).toEqual([
|
||||
ssh,
|
||||
{ ...kuma, visible: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps this node\'s order while taking the row\'s values', () => {
|
||||
useCanvasStore.getState().loadCanvas([drawnAs('n1', [kuma, ssh])], [])
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', {
|
||||
services: [{ ...ssh, path: '/admin' }, kuma],
|
||||
})
|
||||
expect(useCanvasStore.getState().nodes[0].data.services).toEqual([
|
||||
kuma,
|
||||
{ ...ssh, path: '/admin' },
|
||||
])
|
||||
})
|
||||
|
||||
it('brings a service the row gained in hidden', () => {
|
||||
useCanvasStore.getState().loadCanvas([drawnAs('n1', [ssh])], [])
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { services: [ssh, kuma] })
|
||||
expect(useCanvasStore.getState().nodes[0].data.services).toEqual([
|
||||
ssh,
|
||||
{ ...kuma, visible: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('drops one the row lost', () => {
|
||||
useCanvasStore.getState().loadCanvas([drawnAs('n1', [ssh, kuma])], [])
|
||||
useCanvasStore.getState().applyDeviceFacts('d-1', { services: [ssh] })
|
||||
expect(useCanvasStore.getState().nodes[0].data.services).toEqual([ssh])
|
||||
})
|
||||
})
|
||||
@@ -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,13 @@ 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,
|
||||
listArrangedForNode,
|
||||
type FactsBaseline,
|
||||
} from '@/utils/deviceFacts'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
@@ -101,9 +108,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 +129,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 +197,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 +209,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 +251,7 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
editingTextId: null,
|
||||
hideIp: readHideIp(),
|
||||
scanEventTs: 0,
|
||||
factsBaseline: {},
|
||||
serviceStatuses: {},
|
||||
floorMap: null,
|
||||
floorMapEditNonce: 0,
|
||||
@@ -431,12 +455,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 +513,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 +573,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 +922,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 +998,49 @@ 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 }
|
||||
// The row owns the facts, this node owns how they are laid out: an
|
||||
// inventory edit updates the values in place rather than replacing the
|
||||
// arrangement, and a service the row just gained arrives hidden.
|
||||
if (Array.isArray(applied.services)) {
|
||||
data.services = listArrangedForNode(n.data.services, applied.services)
|
||||
}
|
||||
if (Array.isArray(applied.properties)) {
|
||||
data.properties = listArrangedForNode(n.data.properties, applied.properties)
|
||||
}
|
||||
// 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 +1051,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 +1083,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,14 @@ 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
|
||||
/** Whether the node drawing this device shows the service. Per node, not per
|
||||
* device — the same device on another canvas keeps its own answer. Absent
|
||||
* means shown: the flag only appears once the service has been hidden. */
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export type ServiceStatus = 'online' | 'offline' | 'unknown'
|
||||
@@ -103,9 +112,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 +292,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',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user