Three findings from the PR review. A finishing deep rescan threw away an edit in progress. The reset effect in InventoryDeviceModal was keyed on the `device` object, and the parent hands down a fresh one whenever the row is refreshed — including from the poll's own onSaved. Same device, new object, so the effect reset the form and left edit mode, minutes into a scan the user was waiting on. It is keyed on the id now: a refreshed row is not a different device, and only a different device is a reason to throw the form away. The fresh row still reaches the canvas and the grid — gating that behind edit mode would discard the scan result instead, and a save unions the services server-side anyway. A rescan tagged every device it touched as "arp"-discovered, so a Proxmox guest, a rack mount or a hand-added host started answering the network source filter. It carries its own source through instead. The two background wrappers wrote status "failed" where the scanners write "error" for the same condition. Harmonized on "error" — the one Scan History filters and colours; "failed" showed up unlabelled. The frontend union keeps 'failed' for rows already recorded. That last one meant updating an existing assertion in test_scan_run.py: it encoded the old spelling. ha-relevant: yes
1094 lines
42 KiB
Python
1094 lines
42 KiB
Python
import ipaddress
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
from pydantic import BaseModel, field_validator
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_user
|
|
from app.core.config import settings
|
|
from app.db.database import AsyncSessionLocal, get_db
|
|
from app.db.models import (
|
|
Design,
|
|
Edge,
|
|
InventoryDevice,
|
|
InventoryDeviceLink,
|
|
Node,
|
|
RackDevice,
|
|
ScanRun,
|
|
)
|
|
from app.schemas.nodes import NodeCreate
|
|
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_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,
|
|
)
|
|
from app.services.zwave_service import build_zwave_properties
|
|
|
|
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
|
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}
|
|
|
|
|
|
def _ip_tokens(ip: str | None) -> list[str]:
|
|
"""Split a Node/device ``ip`` field into individual addresses.
|
|
|
|
The canvas stores multiple addresses in one comma-separated string (e.g.
|
|
``"fe80::1, 192.168.1.5"`` once a user adds an IPv6 address). Matching a
|
|
scanned device against that field must compare per-address, not against the
|
|
whole string, or the device looks absent from the canvas (issue #258).
|
|
"""
|
|
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
|
|
|
|
|
def _is_rack_only(device: InventoryDevice) -> bool:
|
|
"""True for inventory entries created from a rack canvas.
|
|
|
|
They describe a mount — a patch panel, a shelf, a chassis — not a host to
|
|
document on a logical canvas, so they are never approved onto one.
|
|
"""
|
|
return device.discovery_source == "rack" or "rack" in (device.discovery_sources or [])
|
|
|
|
|
|
def _is_wireless(node_type: str | None) -> bool:
|
|
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
|
|
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
|
|
|
|
|
|
def _wireless_properties(
|
|
node_type: str | None,
|
|
ieee: str | None,
|
|
vendor: str | None,
|
|
model: str | None,
|
|
lqi: int | None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Build the right property rows for a mesh device (Z-Wave has no LQI)."""
|
|
if node_type in _ZWAVE_TYPES:
|
|
return build_zwave_properties(ieee, vendor, model)
|
|
return build_zigbee_properties(ieee, vendor, model, lqi)
|
|
|
|
|
|
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
|
"""Build a NodeProperty list carrying a device MAC address.
|
|
|
|
Shape matches the frontend ``NodeProperty`` type
|
|
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
|
|
showing it on the canvas card from the right panel. Returns an empty list
|
|
when no MAC is known.
|
|
"""
|
|
if not mac:
|
|
return []
|
|
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
|
|
|
|
|
|
def merge_mac_property(
|
|
props: list[dict[str, Any]] | None, mac: str | None
|
|
) -> list[dict[str, Any]]:
|
|
"""Append a MAC NodeProperty to ``props`` unless one is already present.
|
|
|
|
Preserves any user-supplied properties (and an existing MAC row's
|
|
visibility) untouched. Used on approve so the scanned MAC is not lost.
|
|
"""
|
|
out = [dict(p) for p in (props or [])]
|
|
if not mac or any(p.get("key") == "MAC" for p in out):
|
|
return out
|
|
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
|
|
return out
|
|
|
|
|
|
class BulkActionRequest(BaseModel):
|
|
device_ids: list[str]
|
|
# Target design for approved nodes. Falls back to the first design when
|
|
# omitted (keeps older clients working), but the UI should send the active
|
|
# design so approved devices land on the canvas the user is looking at.
|
|
design_id: str | None = None
|
|
|
|
|
|
def _check_port_ranges(v: list[str]) -> list[str]:
|
|
for r in v:
|
|
if not _valid_port_range(r.strip()):
|
|
raise ValueError(f"Invalid port range: {r!r}")
|
|
return v
|
|
|
|
|
|
class ScanConfig(BaseModel):
|
|
"""Persisted scan defaults (Options page). Deep-scan fields are optional."""
|
|
|
|
ranges: list[str]
|
|
http_ranges: list[str] = []
|
|
http_probe_enabled: bool = False
|
|
verify_tls: bool = False
|
|
|
|
@field_validator("ranges")
|
|
@classmethod
|
|
def validate_cidr(cls, v: list[str]) -> list[str]:
|
|
for r in v:
|
|
try:
|
|
ipaddress.ip_network(r, strict=False)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
|
return v
|
|
|
|
@field_validator("http_ranges")
|
|
@classmethod
|
|
def validate_http_ranges(cls, v: list[str]) -> list[str]:
|
|
return _check_port_ranges(v)
|
|
|
|
|
|
class TriggerScanRequest(BaseModel):
|
|
"""Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
|
|
|
|
http_ranges: list[str] | None = None
|
|
http_probe_enabled: bool | None = None
|
|
verify_tls: bool | None = None
|
|
|
|
@field_validator("http_ranges")
|
|
@classmethod
|
|
def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
|
|
return None if v is None else _check_port_ranges(v)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
async def _background_scan(
|
|
run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
|
|
) -> None:
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
|
|
except Exception:
|
|
logger.exception("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()
|
|
|
|
|
|
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()
|
|
|
|
|
|
def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
|
|
"""Merge per-scan overrides over persisted settings defaults."""
|
|
p = payload or TriggerScanRequest()
|
|
return DeepScanOptions(
|
|
http_ranges=(
|
|
p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
|
|
),
|
|
http_probe_enabled=(
|
|
p.http_probe_enabled
|
|
if p.http_probe_enabled is not None
|
|
else settings.scanner_http_probe_enabled
|
|
),
|
|
verify_tls=(
|
|
p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("/trigger", response_model=ScanRunResponse)
|
|
async def trigger_scan(
|
|
background_tasks: BackgroundTasks,
|
|
payload: TriggerScanRequest | None = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> ScanRun:
|
|
ranges = settings.scanner_ranges
|
|
deep_scan = _resolve_deep_scan(payload)
|
|
run = ScanRun(status="running", ranges=ranges)
|
|
db.add(run)
|
|
await db.commit()
|
|
await db.refresh(run)
|
|
background_tasks.add_task(_background_scan, run.id, ranges, deep_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,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, bool]:
|
|
try:
|
|
uuid.UUID(run_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
|
|
run = await db.get(ScanRun, run_id)
|
|
if not run:
|
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
|
if run.status != "running":
|
|
raise HTTPException(status_code=409, detail="Scan is not running")
|
|
request_cancel(run_id)
|
|
# Flip status eagerly so the UI reflects the stop immediately, instead of
|
|
# waiting for run_scan to reach its next cancellation checkpoint (which may
|
|
# be blocked inside a long nmap call). run_scan converges to the same state.
|
|
run.status = "cancelled"
|
|
run.finished_at = datetime.now(timezone.utc)
|
|
await db.commit()
|
|
return {"stopping": True}
|
|
|
|
|
|
def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
|
"""Pick the newest (max) or oldest (min) of a list of timestamps, or None."""
|
|
present = [v for v in values if v is not None]
|
|
if not present:
|
|
return None
|
|
return max(present) if newest else min(present)
|
|
|
|
|
|
async def _canvas_correlation(
|
|
db: AsyncSession, devices: list[InventoryDevice]
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Correlate each device to the canvas nodes drawing it.
|
|
|
|
Returns, per device id: the number of distinct canvases (designs) it appears
|
|
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.
|
|
|
|
``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.device_id, Node.design_id, Node.created_at, Node.updated_at)
|
|
.where(Node.design_id.isnot(None), Node.device_id.isnot(None))
|
|
)
|
|
).all()
|
|
by_device: dict[str, list[Any]] = {}
|
|
for row in rows:
|
|
by_device.setdefault(row.device_id, []).append(row)
|
|
|
|
info: dict[str, dict[str, Any]] = {}
|
|
for d in devices:
|
|
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": d.last_scan,
|
|
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
|
|
"node_last_seen": d.last_seen,
|
|
}
|
|
return info
|
|
|
|
|
|
async def _with_canvas_counts(
|
|
db: AsyncSession, devices: list[InventoryDevice]
|
|
) -> list[InventoryDevice]:
|
|
"""Attach transient canvas count + linked-node timestamps for the response."""
|
|
info = await _canvas_correlation(db, devices)
|
|
for d in devices:
|
|
meta = info.get(d.id, {})
|
|
d.canvas_count = meta.get("canvas_count", 0)
|
|
d.node_created_at = meta.get("node_created_at")
|
|
d.node_last_scan = meta.get("node_last_scan")
|
|
d.node_last_modified = meta.get("node_last_modified")
|
|
d.node_last_seen = meta.get("node_last_seen")
|
|
return devices
|
|
|
|
|
|
@router.get("/pending", response_model=list[InventoryDeviceResponse])
|
|
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
|
|
# Inventory: every scanned device except the user-hidden ones. Approved devices
|
|
# stay listed so they keep showing with a canvas-presence badge.
|
|
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
|
return await _with_canvas_counts(db, list(result.scalars().all()))
|
|
|
|
|
|
@router.post("/pending", response_model=InventoryDeviceResponse, status_code=201)
|
|
async def create_pending(
|
|
body: InventoryDeviceCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> InventoryDevice:
|
|
"""Add an inventory entry by hand, for hardware no scan can discover.
|
|
|
|
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=mac,
|
|
suggested_type=body.suggested_type,
|
|
model=body.model,
|
|
vendor=body.vendor,
|
|
properties=body.properties,
|
|
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()
|
|
await db.refresh(device)
|
|
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),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, int]:
|
|
from sqlalchemy import delete as sa_delete
|
|
result = await db.execute(sa_delete(InventoryDevice).where(InventoryDevice.status == "pending"))
|
|
await db.commit()
|
|
return {"deleted": result.rowcount}
|
|
|
|
|
|
@router.delete("/pending/{device_id}", response_model=dict)
|
|
async def delete_pending(
|
|
device_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, bool]:
|
|
"""Remove one inventory entry.
|
|
|
|
Used by the rack canvas to drop the placeholder it created for a plate once
|
|
that plate is pointed at a real inventory row. Refuses while a rack still
|
|
mounts the device: SQLite runs with foreign keys off here, so the mount's
|
|
``device_id`` would be left naming a row that no longer exists rather than
|
|
being cleared by the ``ON DELETE SET NULL`` the schema declares.
|
|
"""
|
|
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")
|
|
|
|
mounted = (
|
|
await db.execute(select(RackDevice.id).where(RackDevice.device_id == device_id).limit(1))
|
|
).scalar_one_or_none()
|
|
if mounted:
|
|
raise HTTPException(status_code=409, detail="Device is mounted in a rack")
|
|
|
|
await db.delete(device)
|
|
await db.commit()
|
|
return {"deleted": True}
|
|
|
|
|
|
@router.get("/hidden", response_model=list[InventoryDeviceResponse])
|
|
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
|
|
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status == "hidden"))
|
|
return await _with_canvas_counts(db, list(result.scalars().all()))
|
|
|
|
|
|
@router.post("/pending/bulk-approve", response_model=dict)
|
|
async def bulk_approve_devices(
|
|
payload: BulkActionRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
# Repair any legacy same-canvas duplicate nodes before placing more.
|
|
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
|
|
if default_design_id is None:
|
|
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
default_design_id = first_design.id if first_design else None
|
|
|
|
# Accept every selected device that isn't user-hidden. We intentionally do NOT
|
|
# filter on status == "pending": a device's status is global, but canvas
|
|
# membership is per-design. A device approved onto another canvas (or whose
|
|
# node was later deleted) must still be placeable on THIS design. Duplicates
|
|
# are guarded per-design below, not by the global status flag.
|
|
result = await db.execute(
|
|
select(InventoryDevice).where(
|
|
InventoryDevice.id.in_(payload.device_ids),
|
|
InventoryDevice.status != "hidden",
|
|
)
|
|
)
|
|
devices = result.scalars().all()
|
|
|
|
# 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.device_id).where(
|
|
Node.design_id == default_design_id, Node.device_id.isnot(None)
|
|
)
|
|
)
|
|
).all()
|
|
placed: dict[str, Any] = {device_id: nid for nid, device_id in existing}
|
|
|
|
created_nodes: list[Node] = []
|
|
approved_devices: list[InventoryDevice] = []
|
|
skipped_devices: list[dict[str, Any]] = []
|
|
for device in devices:
|
|
# Rack-only gear belongs to a rack canvas, never to a logical one.
|
|
if _is_rack_only(device):
|
|
skipped_devices.append({
|
|
"device_id": device.id,
|
|
"label": device.hostname or device.friendly_name or "device",
|
|
"match": "rack", "value": "rack device", "_ref": None,
|
|
})
|
|
continue
|
|
# 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 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.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 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]
|
|
approved_device_ids = [d.id for d in approved_devices]
|
|
|
|
# Resolve each skip's existing-node reference to a concrete id now that any
|
|
# in-batch nodes have been flushed, and expose it under a clean key.
|
|
for entry in skipped_devices:
|
|
ref = entry.pop("_ref")
|
|
entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref
|
|
|
|
all_edges: list[dict[str, str]] = []
|
|
for device in approved_devices:
|
|
all_edges.extend(
|
|
await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
|
|
)
|
|
|
|
await db.commit()
|
|
return {
|
|
"approved": len(node_ids),
|
|
"node_ids": node_ids,
|
|
"device_ids": approved_device_ids,
|
|
"edges_created": len(all_edges),
|
|
"edges": all_edges,
|
|
"skipped": len(payload.device_ids) - len(node_ids),
|
|
"skipped_devices": skipped_devices,
|
|
}
|
|
|
|
|
|
@router.post("/pending/bulk-hide", response_model=dict)
|
|
async def bulk_hide_devices(
|
|
payload: BulkActionRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
result = await db.execute(
|
|
select(InventoryDevice).where(
|
|
InventoryDevice.id.in_(payload.device_ids),
|
|
InventoryDevice.status == "pending",
|
|
)
|
|
)
|
|
devices = result.scalars().all()
|
|
for device in devices:
|
|
device.status = "hidden"
|
|
await db.commit()
|
|
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
|
|
|
|
|
@router.post("/pending/{device_id}/restore", response_model=dict)
|
|
async def restore_device(
|
|
device_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
device = await db.get(InventoryDevice, device_id)
|
|
if not device:
|
|
raise HTTPException(status_code=404, detail="Device not found")
|
|
if device.status != "hidden":
|
|
raise HTTPException(status_code=409, detail="Device is not hidden")
|
|
device.status = "pending"
|
|
await db.commit()
|
|
return {"restored": True, "device_id": device_id}
|
|
|
|
|
|
@router.post("/pending/bulk-restore", response_model=dict)
|
|
async def bulk_restore_devices(
|
|
payload: BulkActionRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
result = await db.execute(
|
|
select(InventoryDevice).where(
|
|
InventoryDevice.id.in_(payload.device_ids),
|
|
InventoryDevice.status == "hidden",
|
|
)
|
|
)
|
|
devices = result.scalars().all()
|
|
for device in devices:
|
|
device.status = "pending"
|
|
await db.commit()
|
|
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
|
|
|
|
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
|
async def approve_device(
|
|
device_id: str,
|
|
node_data: NodeCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
# Determine target design
|
|
node_design_id = node_data.design_id
|
|
if node_design_id is None:
|
|
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
node_design_id = first.id if first else None
|
|
|
|
device = await db.get(InventoryDevice, device_id)
|
|
if not device:
|
|
raise HTTPException(status_code=404, detail="Device not found")
|
|
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
|
|
# ANY canvas — but canvas membership is per-design. Approving onto a NEW
|
|
# design must therefore work even when the device already sits on another
|
|
# one (mirroring bulk_approve_devices, which deliberately does not filter on
|
|
# status == "pending"). Same-design duplicates are caught by the per-design
|
|
# IEEE/ip/mac guards below, not by this global flag. Only a user-hidden
|
|
# device is off-limits here.
|
|
if device.status == "hidden":
|
|
raise HTTPException(status_code=409, detail="Device is hidden")
|
|
if _is_rack_only(device):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Rack devices cannot be placed on a logical canvas",
|
|
)
|
|
wireless = _is_wireless(node_data.type)
|
|
|
|
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
|
|
# again automatically: the user might genuinely want a second card, or might
|
|
# be re-approving by mistake. Reject with 409 + the existing node so the UI
|
|
# can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
|
|
# hosts. force=True (set after the user confirms) skips this and creates it.
|
|
# The same device on a *different* design is valid (one Node per canvas), so
|
|
# this is scoped to node_design_id.
|
|
if not node_data.force:
|
|
conflict = await find_duplicate_node(
|
|
db, node_design_id,
|
|
node_data.ip or device.ip,
|
|
node_data.mac or device.mac,
|
|
ieee=device.ieee_address,
|
|
)
|
|
if conflict is not None:
|
|
raise HTTPException(status_code=409, detail=conflict)
|
|
|
|
device.status = "approved"
|
|
# 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)
|
|
|
|
# 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(
|
|
merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
|
|
_mac,
|
|
)
|
|
)
|
|
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()
|
|
node_id = node.id
|
|
|
|
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address, node_design_id)
|
|
|
|
await db.commit()
|
|
return {
|
|
"approved": True,
|
|
"node_id": node_id,
|
|
"edges_created": len(edges),
|
|
"edges": edges,
|
|
}
|
|
|
|
|
|
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
|
|
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
|
|
|
|
Such a host needs one left + one right handle for the cluster edge endpoints
|
|
(both default to 0). Checked at approve time, before the link is consumed by
|
|
``_resolve_pending_links_for_ieee``.
|
|
"""
|
|
if not ieee:
|
|
return False
|
|
found = (
|
|
await db.execute(
|
|
select(InventoryDeviceLink.id)
|
|
.where(
|
|
InventoryDeviceLink.discovery_source == "proxmox_cluster",
|
|
(InventoryDeviceLink.source_ieee == ieee) | (InventoryDeviceLink.target_ieee == ieee),
|
|
)
|
|
.limit(1)
|
|
)
|
|
).scalar()
|
|
return found is not None
|
|
|
|
|
|
async def _resolve_pending_links_for_ieee(
|
|
db: AsyncSession, ieee: str | None, design_id: str | None
|
|
) -> list[dict[str, str]]:
|
|
"""Materialize edges for any device_inventory_links involving ``ieee`` on the
|
|
canvas identified by ``design_id``.
|
|
|
|
For each link where the other endpoint already exists as a Node *on this
|
|
design* (matched by ``Node.ieee_address`` + ``Node.design_id``), create the
|
|
Edge. Links are **never** deleted here: they describe the discovered mesh /
|
|
cluster topology and are wiped+reinserted wholesale on the next import
|
|
(zigbee/zwave/proxmox). Keeping them lets the same devices be re-approved
|
|
onto a second canvas with their edges intact.
|
|
"""
|
|
if not ieee:
|
|
return []
|
|
|
|
links_q = await db.execute(
|
|
select(InventoryDeviceLink).where(
|
|
(InventoryDeviceLink.source_ieee == ieee)
|
|
| (InventoryDeviceLink.target_ieee == ieee)
|
|
)
|
|
)
|
|
links = list(links_q.scalars().all())
|
|
if not links:
|
|
return []
|
|
|
|
# Map every relevant ieee → Node *on the target design* (single query).
|
|
# Scoping by design is what makes a re-approve onto a second canvas link the
|
|
# nodes of THAT canvas, not stale nodes left on another one.
|
|
other_ieees = {
|
|
link.target_ieee if link.source_ieee == ieee else link.source_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(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 = {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:
|
|
return []
|
|
|
|
# Pre-fetch existing edges between these node ids so we don't create dups
|
|
# if the user re-approves a device or had drawn the link manually.
|
|
candidate_node_ids = [n.id for n in by_ieee.values()]
|
|
existing_q = await db.execute(
|
|
select(Edge).where(
|
|
Edge.source.in_(candidate_node_ids),
|
|
Edge.target.in_(candidate_node_ids),
|
|
)
|
|
)
|
|
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
|
|
|
|
created: list[dict[str, str]] = []
|
|
for link in links:
|
|
other_ieee = (
|
|
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
|
)
|
|
other_node = by_ieee.get(other_ieee)
|
|
if other_node is None:
|
|
continue
|
|
if link.source_ieee == ieee:
|
|
src_id, tgt_id = self_node.id, other_node.id
|
|
else:
|
|
src_id, tgt_id = other_node.id, self_node.id
|
|
# Skip if either direction already exists on this design (re-approve or
|
|
# a manually drawn link). The link row is kept for other designs.
|
|
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
|
|
continue
|
|
# Edge lands on the design we're approving into (the nodes above are
|
|
# already scoped to it).
|
|
edge_design_id = design_id or (self_node.design_id if self_node else None)
|
|
# Edge shape by link source. Handle IDs are the *bare* slot-0 side names
|
|
# (the canonical stored form — the save path normalizes '<side>-t' → the
|
|
# bare source id, and React Flow resolves the bare id to that side). A
|
|
# '-t' target id does not resolve here and RF falls back to the top
|
|
# handle, so never emit one.
|
|
# proxmox → 'virtual' host→guest, vertical (bottom → top)
|
|
# proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
|
|
# anything else → 'iot' mesh link, vertical
|
|
if link.discovery_source == "proxmox":
|
|
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top"
|
|
elif link.discovery_source == "proxmox_cluster":
|
|
edge_type, src_handle, tgt_handle = "cluster", "right", "left"
|
|
else:
|
|
edge_type, src_handle, tgt_handle = "iot", "bottom", "top"
|
|
edge = Edge(
|
|
source=src_id,
|
|
target=tgt_id,
|
|
type=edge_type,
|
|
source_handle=src_handle,
|
|
target_handle=tgt_handle,
|
|
design_id=edge_design_id,
|
|
)
|
|
db.add(edge)
|
|
await db.flush()
|
|
existing_pairs.add((src_id, tgt_id))
|
|
# Return the edge's type + handles so the client injects it faithfully
|
|
# (a cluster edge must keep its right→left handles, not the iot default).
|
|
created.append({
|
|
"id": edge.id,
|
|
"source": src_id,
|
|
"target": tgt_id,
|
|
"type": edge_type,
|
|
"source_handle": src_handle,
|
|
"target_handle": tgt_handle,
|
|
})
|
|
|
|
return created
|
|
|
|
|
|
@router.post("/pending/{device_id}/hide")
|
|
async def hide_device(
|
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
|
) -> dict[str, bool]:
|
|
device = await db.get(InventoryDevice, device_id)
|
|
if not device:
|
|
raise HTTPException(status_code=404, detail="Device not found")
|
|
device.status = "hidden"
|
|
await db.commit()
|
|
return {"hidden": True}
|
|
|
|
|
|
@router.post("/pending/{device_id}/ignore")
|
|
async def ignore_device(
|
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
|
) -> dict[str, bool]:
|
|
device = await db.get(InventoryDevice, device_id)
|
|
if not device:
|
|
raise HTTPException(status_code=404, detail="Device not found")
|
|
await db.delete(device)
|
|
await db.commit()
|
|
return {"ignored": True}
|
|
|
|
|
|
@router.get("/runs", response_model=list[ScanRunResponse])
|
|
async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[ScanRun]:
|
|
result = await db.execute(select(ScanRun).order_by(ScanRun.started_at.desc()).limit(20))
|
|
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(
|
|
ranges=settings.scanner_ranges,
|
|
http_ranges=settings.scanner_http_ranges,
|
|
http_probe_enabled=settings.scanner_http_probe_enabled,
|
|
verify_tls=settings.scanner_http_verify_tls,
|
|
)
|
|
|
|
|
|
@router.post("/config", response_model=ScanConfig)
|
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
|
previous = (
|
|
settings.scanner_ranges,
|
|
settings.scanner_http_ranges,
|
|
settings.scanner_http_probe_enabled,
|
|
settings.scanner_http_verify_tls,
|
|
)
|
|
settings.scanner_ranges = payload.ranges
|
|
settings.scanner_http_ranges = payload.http_ranges
|
|
settings.scanner_http_probe_enabled = payload.http_probe_enabled
|
|
settings.scanner_http_verify_tls = payload.verify_tls
|
|
try:
|
|
settings.save_overrides()
|
|
return payload
|
|
except Exception as exc:
|
|
(
|
|
settings.scanner_ranges,
|
|
settings.scanner_http_ranges,
|
|
settings.scanner_http_probe_enabled,
|
|
settings.scanner_http_verify_tls,
|
|
) = previous
|
|
logger.error("Failed to save scan config: %s", exc)
|
|
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|