Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
322600f1c9 | ||
|
|
9c2a1ba0eb | ||
|
|
851141951d | ||
|
|
f9f88c8fb0 | ||
|
|
2bf9f1bca8 | ||
|
|
d9451f35dc | ||
|
|
05c0da53e6 | ||
|
|
c29680a75f | ||
|
|
eb7e74d36b | ||
|
|
390a712f14 | ||
|
|
9daea4c4d0 | ||
|
|
2e642815f4 | ||
|
|
c6d6b525ea | ||
|
|
fbb660504e | ||
|
|
1be96d1045 | ||
|
|
9bc02d61c2 | ||
|
|
21a64e52ab | ||
|
|
ad0b1b427d | ||
|
|
4c460cec1c | ||
|
|
1aa88de491 | ||
|
|
d0679ad72b | ||
|
|
5df70ce128 | ||
|
|
a58725c2ee | ||
|
|
466df59b0c | ||
|
|
7eec0b3f9a | ||
|
|
b76519b0dd | ||
|
|
a2870a6e29 |
@@ -89,6 +89,10 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# ZIGBEE_BASE_TOPIC=zigbee2mqtt
|
||||
# ZIGBEE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
||||
# ZIGBEE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
||||
# Seconds to wait for the Z2M bridge to answer a networkmap request. Applies to
|
||||
# manual imports too. Raise it on a large mesh (200+ devices can take minutes)
|
||||
# if an import fails with "Timed out waiting for networkmap response".
|
||||
# ZIGBEE_NETWORKMAP_TIMEOUT=300
|
||||
|
||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee.
|
||||
# ZWAVE_MQTT_HOST=192.168.1.20
|
||||
@@ -99,3 +103,6 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# ZWAVE_GATEWAY_NAME=zwavejs2mqtt
|
||||
# ZWAVE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
||||
# ZWAVE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
||||
# Seconds to wait for the gateway to answer an MQTT request. Same rationale as
|
||||
# ZIGBEE_NETWORKMAP_TIMEOUT above.
|
||||
# MQTT_RESPONSE_TIMEOUT=300
|
||||
|
||||
+46
-1
@@ -5,6 +5,51 @@ 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
|
||||
@@ -20,7 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
### Fixes
|
||||
|
||||
- Alignment guides show for nodes inside groups and containers. (#337)
|
||||
- The status check interval is applied to the running scheduler. (#319) — thanks @JaG-v2
|
||||
- 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
|
||||
|
||||
@@ -48,8 +48,8 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
|
||||
<p align="center">
|
||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||
<img alt="Homelable Device inventory" src="https://github.com/user-attachments/assets/f3903ac8-354d-4873-81ba-1914971890ed" />
|
||||
<img width="49.5%" alt="Homelable Custom node" src="https://github.com/user-attachments/assets/813725b1-376b-4bad-bb1f-0985f3bc7546" />
|
||||
<img width="49.5%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/35e18d11-8363-498d-ae3d-642685cac76d" />
|
||||
|
||||
<img alt="Rack display" src="https://github.com/user-attachments/assets/43273605-4f46-4163-8aea-8bf8a76a3f76" />
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
@@ -32,7 +32,14 @@ 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, request_cancel, run_scan
|
||||
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,
|
||||
@@ -176,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()
|
||||
|
||||
|
||||
@@ -215,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,
|
||||
@@ -933,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(
|
||||
|
||||
@@ -19,6 +19,8 @@ from app.schemas.zigbee import (
|
||||
ZigbeeConfig,
|
||||
ZigbeeCoordinatorOut,
|
||||
ZigbeeEdgeOut,
|
||||
ZigbeeImportJob,
|
||||
ZigbeeImportJobResult,
|
||||
ZigbeeImportPendingResponse,
|
||||
ZigbeeImportRequest,
|
||||
ZigbeeImportResponse,
|
||||
@@ -27,6 +29,7 @@ from app.schemas.zigbee import (
|
||||
ZigbeeTestConnectionRequest,
|
||||
ZigbeeTestConnectionResponse,
|
||||
)
|
||||
from app.services.import_jobs import create_job, fail_job, finish_job, get_job
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
@@ -48,18 +51,70 @@ async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ZigbeeImportResponse)
|
||||
def _import_error(exc: BaseException) -> tuple[int, str]:
|
||||
"""Map a fetch_networkmap failure to the (status, detail) the API reports."""
|
||||
if isinstance(exc, ImportError):
|
||||
return 500, str(exc)
|
||||
if isinstance(exc, ConnectionError):
|
||||
return 502, str(exc)
|
||||
if isinstance(exc, TimeoutError):
|
||||
return 504, str(exc)
|
||||
if isinstance(exc, ValueError):
|
||||
return 422, str(exc)
|
||||
logger.exception("Unexpected error during Zigbee import", exc_info=exc)
|
||||
return 500, "Unexpected error during Zigbee import"
|
||||
|
||||
|
||||
@router.post("/import", response_model=ZigbeeImportJob, status_code=202)
|
||||
async def import_zigbee_network(
|
||||
payload: ZigbeeImportRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeImportResponse:
|
||||
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
||||
) -> ZigbeeImportJob:
|
||||
"""Start a canvas import and return a job id to poll.
|
||||
|
||||
Connects to the specified MQTT broker, publishes a networkmap request to
|
||||
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
|
||||
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
|
||||
coordinator → router → end-device hierarchy.
|
||||
``<base_topic>/bridge/request/networkmap`` and waits up to
|
||||
``ZIGBEE_NETWORKMAP_TIMEOUT`` seconds (default 300) for the response.
|
||||
|
||||
The fetch runs in the background and the result is collected from
|
||||
``GET /zigbee/import/{job_id}``: a 200+ device mesh takes minutes to answer,
|
||||
which outlives the read timeout of any reverse proxy sitting in front of the
|
||||
API. Polling keeps each request short.
|
||||
"""
|
||||
job = create_job()
|
||||
background_tasks.add_task(_background_canvas_import, job.id, payload)
|
||||
return ZigbeeImportJob(job_id=job.id, status=job.status)
|
||||
|
||||
|
||||
@router.get("/import/{job_id}", response_model=ZigbeeImportJobResult)
|
||||
async def get_zigbee_import_job(
|
||||
job_id: str,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeImportJobResult:
|
||||
"""Poll a canvas import started by ``POST /zigbee/import``.
|
||||
|
||||
While running, returns ``status="running"`` with no payload. On success the
|
||||
nodes and edges are carried in ``result``. A failed fetch is reported as the
|
||||
status code the synchronous route used to raise, so the client keeps
|
||||
distinguishing a bad broker (502) from a slow mesh (504).
|
||||
"""
|
||||
job = get_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Import job not found or expired")
|
||||
if job.status == "error":
|
||||
raise HTTPException(
|
||||
status_code=job.error_status or 500,
|
||||
detail=job.error or "Zigbee import failed",
|
||||
)
|
||||
result = None
|
||||
if job.status == "done" and job.result is not None:
|
||||
result = ZigbeeImportResponse(**job.result)
|
||||
return ZigbeeImportJobResult(job_id=job.id, status=job.status, result=result)
|
||||
|
||||
|
||||
async def _background_canvas_import(job_id: str, payload: ZigbeeImportRequest) -> None:
|
||||
"""Fetch the network map and park the canvas-ready payload on the job."""
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_networkmap(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
@@ -70,21 +125,19 @@ async def import_zigbee_network(
|
||||
tls=payload.mqtt_tls,
|
||||
tls_insecure=payload.mqtt_tls_insecure,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
except ConnectionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during Zigbee import")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
|
||||
status, detail = _import_error(exc)
|
||||
fail_job(job_id, detail, status)
|
||||
return
|
||||
|
||||
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
|
||||
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
|
||||
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||
finish_job(
|
||||
job_id,
|
||||
ZigbeeImportResponse(
|
||||
nodes=nodes, edges=edges, device_count=len(nodes)
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||
|
||||
@@ -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] = []
|
||||
@@ -152,6 +179,10 @@ class Settings(BaseSettings):
|
||||
zigbee_mqtt_tls_insecure: bool = False
|
||||
zigbee_sync_enabled: bool = False
|
||||
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
# Seconds to wait for the Z2M bridge to answer a networkmap request. A mesh
|
||||
# of 200+ devices can take several minutes to build the map, so raise this
|
||||
# if imports fail with "Timed out waiting for networkmap response".
|
||||
zigbee_networkmap_timeout: int = 300
|
||||
|
||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
|
||||
zwave_mqtt_host: str = ""
|
||||
@@ -165,6 +196,10 @@ class Settings(BaseSettings):
|
||||
zwave_sync_enabled: bool = False
|
||||
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
# Seconds to wait for any MQTT gateway request/response round-trip
|
||||
# (the Z-Wave node dump today). Same rationale as the Zigbee twin above.
|
||||
mqtt_response_timeout: int = 300
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+231
-18
@@ -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
|
||||
|
||||
@@ -467,6 +469,10 @@ async def init_db() -> None:
|
||||
"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 (
|
||||
@@ -487,6 +493,8 @@ async def init_db() -> None:
|
||||
|
||||
await _backfill_node_devices()
|
||||
await _drop_legacy_node_columns()
|
||||
await _seed_node_views()
|
||||
await _backfill_zone_size()
|
||||
|
||||
|
||||
|
||||
@@ -507,6 +515,7 @@ _NODE_COLUMNS_SQL = (
|
||||
"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,"
|
||||
@@ -525,19 +534,70 @@ _NODE_COLUMNS_SQL = (
|
||||
)
|
||||
|
||||
_NODE_KEPT = (
|
||||
"id, type, label, design_id, device_id, pos_x, pos_y, parent_id, container_mode, "
|
||||
"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.
|
||||
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()
|
||||
@@ -557,23 +617,16 @@ async def _drop_legacy_node_columns() -> None:
|
||||
"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")
|
||||
try:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(f"CREATE TABLE nodes_new ({_NODE_COLUMNS_SQL})")
|
||||
await conn.exec_driver_sql(
|
||||
f"INSERT INTO nodes_new ({_NODE_KEPT}) SELECT {_NODE_KEPT} 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)"
|
||||
)
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
except OperationalError as exc:
|
||||
logger.warning("nodes device-column drop failed: %s", exc)
|
||||
await _rebuild_nodes(
|
||||
conn,
|
||||
columns_sql=_NODE_COLUMNS_SQL,
|
||||
copied=_NODE_KEPT,
|
||||
what="nodes device-column drop",
|
||||
)
|
||||
|
||||
|
||||
async def _backfill_node_devices() -> None:
|
||||
@@ -596,10 +649,170 @@ async def _backfill_node_devices() -> None:
|
||||
"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 _backfill_zone_size() -> None:
|
||||
"""Move a zone's size out of the custom_colors blob into the real columns.
|
||||
|
||||
Every node type stored its size in `nodes.width` / `nodes.height` except
|
||||
`groupRect`, which stashed it inside the `custom_colors` JSON alongside its
|
||||
colours. The serializer writes the columns for zones too now, so a canvas
|
||||
saved before this upgrade would come back at the default 360x240 without
|
||||
this backfill.
|
||||
|
||||
Only fills a column that is still NULL, so it cannot overwrite a size the
|
||||
user has set since, and re-running it is a no-op. Parsed in Python rather
|
||||
than with `json_extract`, so it does not depend on the SQLite build being
|
||||
compiled with JSON1.
|
||||
|
||||
Never fatal: the reader falls back to the blob, so the worst case of a
|
||||
failure here is that the geometry keeps coming from where it always did.
|
||||
"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT id, custom_colors FROM nodes "
|
||||
"WHERE type = 'groupRect' AND custom_colors IS NOT NULL "
|
||||
"AND (width IS NULL OR height IS NULL)"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
moved = 0
|
||||
for node_id, blob in rows:
|
||||
if isinstance(blob, str):
|
||||
try:
|
||||
blob = _json.loads(blob)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(blob, dict):
|
||||
continue
|
||||
|
||||
width, height = blob.get("width"), blob.get("height")
|
||||
# A bool is an int in Python; a size that is not a real number
|
||||
# is left alone rather than written as garbage.
|
||||
if not isinstance(width, int | float) or isinstance(width, bool):
|
||||
width = None
|
||||
if not isinstance(height, int | float) or isinstance(height, bool):
|
||||
height = None
|
||||
if width is None and height is None:
|
||||
continue
|
||||
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET width = COALESCE(width, ?), height = COALESCE(height, ?) "
|
||||
"WHERE id = ?",
|
||||
(width, height, node_id),
|
||||
)
|
||||
moved += 1
|
||||
|
||||
if moved:
|
||||
logger.info("Moved the size of %d zone(s) to the width/height columns", moved)
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Backfilling zone width/height failed: %s", exc)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
@@ -49,6 +49,15 @@ class Node(Base):
|
||||
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"))
|
||||
|
||||
+6
-1
@@ -28,7 +28,8 @@ from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
from app.core.security import OIDCCSRFMiddleware
|
||||
from app.db.database import init_db
|
||||
from app.db.database import AsyncSessionLocal, init_db
|
||||
from app.services.scanner import reconcile_orphan_runs
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -45,6 +46,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
|
||||
await init_db()
|
||||
settings.load_overrides()
|
||||
# A scan runs on a background thread in this process, so anything still
|
||||
# flagged "running" now was orphaned by a previous one that died mid-scan.
|
||||
async with AsyncSessionLocal() as db:
|
||||
await reconcile_orphan_runs(db)
|
||||
start_scheduler()
|
||||
yield
|
||||
stop_scheduler()
|
||||
|
||||
@@ -73,6 +73,21 @@ class ZigbeeImportResponse(BaseModel):
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZigbeeImportJob(BaseModel):
|
||||
"""Handle returned when a canvas import is queued."""
|
||||
|
||||
job_id: str
|
||||
status: str # running | done | error
|
||||
|
||||
|
||||
class ZigbeeImportJobResult(BaseModel):
|
||||
"""Poll response for a canvas import. ``result`` is set once done."""
|
||||
|
||||
job_id: str
|
||||
status: str # running | done
|
||||
result: ZigbeeImportResponse | None = None
|
||||
|
||||
|
||||
class ZigbeeTestConnectionResponse(BaseModel):
|
||||
connected: bool
|
||||
message: str
|
||||
|
||||
@@ -16,7 +16,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Headers that commonly carry the application name.
|
||||
_SIGNAL_HEADERS = ("Server", "X-Powered-By")
|
||||
# Cap how much body we read when hunting for <title> — avoids large downloads.
|
||||
# Cap how much body we read when hunting for <title>. This is a cap on the
|
||||
# download itself, not just on what we scan: the body is streamed and the
|
||||
# connection dropped once we hold this much. Some endpoints stream without end
|
||||
# and send no Content-Length (bandwidth-test endpoints, MJPEG cameras), so
|
||||
# buffering a full response would OOM the backend.
|
||||
_MAX_BODY_BYTES = 64 * 1024
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
_PROBE_TIMEOUT = 3.0
|
||||
@@ -24,6 +28,23 @@ _PROBE_TIMEOUT = 3.0
|
||||
_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379})
|
||||
|
||||
|
||||
async def _read_capped(resp: httpx.Response) -> str:
|
||||
"""Read at most _MAX_BODY_BYTES of a streaming response, then stop."""
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in resp.aiter_bytes():
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
if size >= _MAX_BODY_BYTES:
|
||||
break
|
||||
raw = b"".join(chunks)[:_MAX_BODY_BYTES]
|
||||
encoding = resp.charset_encoding or "utf-8"
|
||||
try:
|
||||
return raw.decode(encoding, errors="replace")
|
||||
except LookupError:
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _extract_title(body: str) -> str | None:
|
||||
m = _TITLE_RE.search(body)
|
||||
if not m:
|
||||
@@ -34,11 +55,11 @@ def _extract_title(body: str) -> str | None:
|
||||
|
||||
async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
async with client.stream("GET", url, follow_redirects=True) as resp:
|
||||
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
|
||||
body = await _read_capped(resp)
|
||||
except (httpx.HTTPError, OSError):
|
||||
return None
|
||||
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
|
||||
body = resp.text[:_MAX_BODY_BYTES] if resp.text else ""
|
||||
title = _extract_title(body)
|
||||
if not title and not headers:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""In-process registry for long-running import jobs that return a payload.
|
||||
|
||||
Pending imports already run in the background and report through ``scan_runs``.
|
||||
Canvas imports are different: the caller wants the fetched map *back* so it can
|
||||
drop it on the canvas, which kept the request open for the whole MQTT
|
||||
round-trip. On a large mesh that outlives any reverse proxy's read timeout
|
||||
(Cloudflare cuts at 100 s and returns a 524), so the browser never sees the
|
||||
result even though the fetch succeeded server-side.
|
||||
|
||||
The fix is to hand the client a job id immediately and let it poll. Results are
|
||||
transient — a canvas import is meaningless once the user has closed the modal —
|
||||
so they live in memory rather than the DB, and expire on a TTL. The backend runs
|
||||
a single uvicorn worker, the same assumption the scheduler and ``BackgroundTasks``
|
||||
already make.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
JobStatus = Literal["running", "done", "error"]
|
||||
|
||||
# How long a finished job stays readable. Long enough for a client that polls
|
||||
# slowly or reloads mid-import, short enough that a forgotten map is not held
|
||||
# for the life of the process.
|
||||
JOB_TTL_SECONDS = 900.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportJob:
|
||||
id: str
|
||||
status: JobStatus = "running"
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
# HTTP status the synchronous route would have raised, so the client can
|
||||
# keep reacting to failures the way it always has.
|
||||
error_status: int | None = None
|
||||
created_at: float = field(default_factory=time.monotonic)
|
||||
finished_at: float | None = None
|
||||
|
||||
|
||||
_jobs: dict[str, ImportJob] = {}
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
for job_id, job in list(_jobs.items()):
|
||||
if job.finished_at is not None and now - job.finished_at > JOB_TTL_SECONDS:
|
||||
del _jobs[job_id]
|
||||
|
||||
|
||||
def create_job() -> ImportJob:
|
||||
"""Register a new running job and return it."""
|
||||
now = time.monotonic()
|
||||
_purge_expired(now)
|
||||
job = ImportJob(id=str(uuid.uuid4()))
|
||||
_jobs[job.id] = job
|
||||
return job
|
||||
|
||||
|
||||
def get_job(job_id: str) -> ImportJob | None:
|
||||
"""Return a job, or None if it never existed or has expired."""
|
||||
_purge_expired(time.monotonic())
|
||||
return _jobs.get(job_id)
|
||||
|
||||
|
||||
def finish_job(job_id: str, result: dict[str, Any]) -> None:
|
||||
"""Mark a job done and attach its payload. No-op if it expired."""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.status = "done"
|
||||
job.result = result
|
||||
job.finished_at = time.monotonic()
|
||||
|
||||
|
||||
def fail_job(job_id: str, error: str, status: int) -> None:
|
||||
"""Mark a job failed with a client-safe message. No-op if it expired."""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.status = "error"
|
||||
job.error = error
|
||||
job.error_status = status
|
||||
job.finished_at = time.monotonic()
|
||||
|
||||
|
||||
def reset_jobs() -> None:
|
||||
"""Drop every job. For tests."""
|
||||
_jobs.clear()
|
||||
@@ -15,10 +15,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select, text
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
@@ -66,6 +67,50 @@ 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,
|
||||
*,
|
||||
@@ -83,7 +128,10 @@ async def find_device_for(
|
||||
ip_toks = _ip_tokens(ip)
|
||||
conds = []
|
||||
if ieee:
|
||||
conds.append(InventoryDevice.ieee_address == 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.
|
||||
@@ -98,7 +146,7 @@ async def find_device_for(
|
||||
).scalars().all()
|
||||
|
||||
for device in candidates:
|
||||
if ieee and device.ieee_address == ieee:
|
||||
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
|
||||
@@ -155,8 +203,181 @@ def _service_key(svc: Any) -> Any:
|
||||
return (svc.get("port"), svc.get("protocol"), (svc.get("service_name") or "").lower())
|
||||
|
||||
|
||||
def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
|
||||
"""Union two service lists on (port, protocol, name); incoming wins."""
|
||||
# --- 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 []:
|
||||
@@ -166,7 +387,12 @@ def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[A
|
||||
out.append(dict(svc) if isinstance(svc, dict) else svc)
|
||||
index[key] = len(out) - 1
|
||||
elif isinstance(svc, dict) and isinstance(out[pos], dict):
|
||||
out[pos] = {**out[pos], **svc}
|
||||
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
|
||||
@@ -330,6 +556,7 @@ async def link_facts(
|
||||
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.
|
||||
|
||||
@@ -360,6 +587,12 @@ async def link_facts(
|
||||
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)
|
||||
@@ -380,6 +613,13 @@ async def link_facts(
|
||||
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
|
||||
|
||||
|
||||
@@ -390,10 +630,16 @@ def node_columns(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
inventory row now, so they are dropped here and applied through
|
||||
:func:`link_facts` instead.
|
||||
"""
|
||||
allowed = {c.name for c in Node.__table__.columns}
|
||||
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")
|
||||
@@ -448,6 +694,9 @@ def hydrated_node(node: Node, device: InventoryDevice | None) -> dict[str, Any]:
|
||||
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
|
||||
|
||||
@@ -455,8 +704,8 @@ def hydrated_node(node: Node, device: InventoryDevice | None) -> dict[str, Any]:
|
||||
payload[field] = getattr(device, field, None)
|
||||
payload["label"] = device.label or node.label
|
||||
payload["type"] = device.type or node.type
|
||||
payload["services"] = device.services or []
|
||||
payload["properties"] = device.properties or []
|
||||
payload["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"
|
||||
@@ -494,6 +743,139 @@ def _decode_json(value: Any) -> Any:
|
||||
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.
|
||||
|
||||
@@ -507,44 +889,84 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
|
||||
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. Does not commit.
|
||||
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}
|
||||
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, {placeholders} FROM nodes "
|
||||
f"WHERE device_id IS NULL AND type NOT IN ({furniture}) "
|
||||
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}
|
||||
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
created = merged = 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"]
|
||||
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
device = await link_facts(db, node, facts, overwrite_scalars=True)
|
||||
if device is None:
|
||||
# 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(
|
||||
@@ -558,4 +980,4 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
return {"linked": len(rows), "created": created, "merged": merged}
|
||||
return {"linked": linked, "created": created, "merged": merged, "skipped": skipped}
|
||||
|
||||
@@ -13,6 +13,8 @@ import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -21,7 +23,10 @@ except ImportError: # pragma: no cover
|
||||
aiomqtt = None # type: ignore[assignment]
|
||||
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_RESPONSE_TIMEOUT = 300.0 # seconds to wait for a gateway response (large meshes are slow)
|
||||
# Fallback wait for a gateway response. The effective value comes from
|
||||
# ``settings.mqtt_response_timeout`` (env MQTT_RESPONSE_TIMEOUT), read at call
|
||||
# time so an operator with a large mesh can raise it without a code change.
|
||||
_RESPONSE_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||
@@ -71,11 +76,14 @@ async def request_response(
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
response_timeout: float = _RESPONSE_TIMEOUT,
|
||||
response_timeout: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Publish ``request_payload`` to ``request_topic`` and return the first
|
||||
JSON message received on ``response_topic`` as a dict.
|
||||
|
||||
``response_timeout`` defaults to ``settings.mqtt_response_timeout``
|
||||
(env ``MQTT_RESPONSE_TIMEOUT``, 300 s) — a large mesh can take minutes.
|
||||
|
||||
Raises:
|
||||
ImportError: if aiomqtt is not installed.
|
||||
TimeoutError: if no response arrives in time.
|
||||
@@ -88,6 +96,14 @@ async def request_response(
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
timeout = (
|
||||
float(settings.mqtt_response_timeout)
|
||||
if response_timeout is None
|
||||
else response_timeout
|
||||
)
|
||||
if timeout <= 0:
|
||||
timeout = _RESPONSE_TIMEOUT
|
||||
|
||||
response_payload: dict[str, Any] = {}
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
@@ -122,12 +138,15 @@ async def request_response(
|
||||
raise ValueError(f"Malformed MQTT response: {exc}") from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=response_timeout)
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=timeout)
|
||||
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Timed out waiting for MQTT response") from exc
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for MQTT response after {timeout:g}s — "
|
||||
"raise MQTT_RESPONSE_TIMEOUT if your mesh is large"
|
||||
) from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty MQTT response received")
|
||||
|
||||
+382
-77
@@ -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
|
||||
@@ -19,6 +20,7 @@ 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,81 +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.
|
||||
# Stamp last_scan on the row so every canvas drawing the device
|
||||
# shows when the scanner last observed it. The row is the one
|
||||
# place that fact belongs; a node only draws it.
|
||||
keep.last_scan = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
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),
|
||||
))
|
||||
if outcome == "created":
|
||||
devices_found += 1
|
||||
|
||||
await db.commit()
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
# nmap scan per CIDR — results stream in progressively
|
||||
@@ -672,3 +815,165 @@ 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)
|
||||
|
||||
|
||||
async def reconcile_orphan_runs(db: AsyncSession) -> int:
|
||||
"""
|
||||
Mark every scan run still flagged `running` at startup as errored.
|
||||
|
||||
Scans live on a background thread inside this process, so nothing can
|
||||
legitimately be `running` the moment we boot: any such row belongs to a
|
||||
previous process that died mid-scan (an OOM kill, `docker stop`, a crash).
|
||||
Left alone the row is immortal, and it also locks the target out — the
|
||||
trigger endpoints reject a new scan while one is `running` for the same
|
||||
range, so a single kill blocks re-scanning for good.
|
||||
|
||||
Returns the number of rows reconciled.
|
||||
"""
|
||||
orphans = (
|
||||
await db.execute(select(ScanRun).where(ScanRun.status == "running"))
|
||||
).scalars().all()
|
||||
if not orphans:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc)
|
||||
for run in orphans:
|
||||
# "error" is the word run_scan and run_device_scan already write when
|
||||
# they fail; Scan History filters and colours that one.
|
||||
run.status = "error"
|
||||
run.error = "Interrupted: the backend restarted while this scan was running"
|
||||
run.finished_at = now
|
||||
await db.commit()
|
||||
logger.warning("Reconciled %d scan run(s) left running by a previous process", len(orphans))
|
||||
return len(orphans)
|
||||
|
||||
@@ -103,8 +103,16 @@ async def _ping(host: str) -> bool:
|
||||
|
||||
|
||||
async def _http_get(url: str, verify: bool = False) -> bool:
|
||||
async with httpx.AsyncClient(verify=verify, timeout=5) as client:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
# Only the status line matters here. A plain .get() buffers the whole body
|
||||
# first, and some endpoints stream without end (bandwidth-test endpoints,
|
||||
# MJPEG cameras, log tails) — enough to OOM the backend. timeout=5 does not
|
||||
# save us: httpx applies it per network operation, not to the total time
|
||||
# spent draining a socket that keeps delivering data. stream() closes the
|
||||
# connection on exit without draining it.
|
||||
async with (
|
||||
httpx.AsyncClient(verify=verify, timeout=5) as client,
|
||||
client.stream("GET", url, follow_redirects=True) as resp,
|
||||
):
|
||||
return resp.status_code < 500
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,7 +20,10 @@ except ImportError: # pragma: no cover
|
||||
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
|
||||
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||
# Fallback wait for the networkmap response. The effective value comes from
|
||||
# ``settings.zigbee_networkmap_timeout`` (env ZIGBEE_NETWORKMAP_TIMEOUT), read at
|
||||
# call time so an operator with a large mesh can raise it without a code change.
|
||||
_NETWORKMAP_TIMEOUT = 300.0
|
||||
|
||||
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||
@@ -238,9 +242,14 @@ async def fetch_networkmap(
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
response_timeout: float | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||
|
||||
``response_timeout`` defaults to ``settings.zigbee_networkmap_timeout``
|
||||
(env ``ZIGBEE_NETWORKMAP_TIMEOUT``, 300 s) — a 200+ device mesh can take
|
||||
minutes to answer.
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the broker does not respond in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
@@ -252,6 +261,14 @@ async def fetch_networkmap(
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
timeout = (
|
||||
float(settings.zigbee_networkmap_timeout)
|
||||
if response_timeout is None
|
||||
else response_timeout
|
||||
)
|
||||
if timeout <= 0:
|
||||
timeout = _NETWORKMAP_TIMEOUT
|
||||
|
||||
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||
|
||||
@@ -295,12 +312,15 @@ async def fetch_networkmap(
|
||||
) from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=timeout)
|
||||
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Timed out waiting for networkmap response") from exc
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for networkmap response after {timeout:g}s — "
|
||||
"raise ZIGBEE_NETWORKMAP_TIMEOUT if your mesh is large"
|
||||
) from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty networkmap response received")
|
||||
|
||||
@@ -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()
|
||||
@@ -414,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
|
||||
|
||||
@@ -8,13 +8,23 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||
from app.services.scanner import (
|
||||
_cancelled_runs,
|
||||
reconcile_orphan_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 +46,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:
|
||||
@@ -436,3 +446,128 @@ 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"
|
||||
|
||||
|
||||
# --- reconcile_orphan_runs (issue #374) --------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_marks_running_as_error(mem_db):
|
||||
"""A run left "running" by a process that died mid-scan is immortal, and it
|
||||
also locks its range out of future scans. Startup must reconcile it."""
|
||||
async with mem_db() as session:
|
||||
orphan = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(orphan)
|
||||
await session.commit()
|
||||
orphan_id = orphan.id
|
||||
|
||||
assert await reconcile_orphan_runs(session) == 1
|
||||
|
||||
reconciled = await session.get(ScanRun, orphan_id)
|
||||
# "error", not "failed" — one condition, one name, and the only status
|
||||
# Scan History filters and colours.
|
||||
assert reconciled.status == "error"
|
||||
assert reconciled.finished_at is not None
|
||||
assert "restarted" in reconciled.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_leaves_finished_runs_alone(mem_db):
|
||||
"""Only "running" is orphaned. Terminal rows must not be rewritten."""
|
||||
async with mem_db() as session:
|
||||
done = ScanRun(status="done", ranges=["10.0.0.0/24"], devices_found=7)
|
||||
cancelled = ScanRun(status="cancelled", ranges=["10.0.1.0/24"])
|
||||
errored = ScanRun(status="error", ranges=["10.0.2.0/24"], error="nmap missing")
|
||||
session.add_all([done, cancelled, errored])
|
||||
await session.commit()
|
||||
ids = (done.id, cancelled.id, errored.id)
|
||||
|
||||
assert await reconcile_orphan_runs(session) == 0
|
||||
|
||||
for run_id, expected in zip(ids, ("done", "cancelled", "error"), strict=True):
|
||||
assert (await session.get(ScanRun, run_id)).status == expected
|
||||
# The pre-existing error message must survive untouched.
|
||||
assert (await session.get(ScanRun, ids[2])).error == "nmap missing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_handles_several_and_an_empty_table(mem_db):
|
||||
async with mem_db() as session:
|
||||
# Nothing to do on a fresh database.
|
||||
assert await reconcile_orphan_runs(session) == 0
|
||||
|
||||
session.add_all([
|
||||
ScanRun(status="running", ranges=["10.0.0.0/24"]),
|
||||
ScanRun(status="running", kind="device", ranges=["10.0.0.5"]),
|
||||
ScanRun(status="done", ranges=["10.0.1.0/24"]),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
assert await reconcile_orphan_runs(session) == 2
|
||||
remaining = (
|
||||
await session.execute(select(ScanRun).where(ScanRun.status == "running"))
|
||||
).scalars().all()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_unblocks_a_new_scan(mem_db):
|
||||
"""The point of the reconcile: the trigger endpoints reject a scan while one
|
||||
is "running" for the same target, so an orphan blocks that range for good."""
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(status="running", kind="device", ranges=["10.0.0.5"]))
|
||||
await session.commit()
|
||||
|
||||
await reconcile_orphan_runs(session)
|
||||
|
||||
blocking = (
|
||||
await session.execute(
|
||||
select(ScanRun).where(
|
||||
ScanRun.status == "running", ScanRun.kind == "device"
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert not any("10.0.0.5" in (r.ranges or []) for r in blocking)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_reconciles_orphan_runs_at_startup():
|
||||
"""The reconcile is worthless unless startup actually calls it."""
|
||||
from app.main import app as fastapi_app
|
||||
from app.main import lifespan
|
||||
|
||||
with patch("app.main.init_db", new=AsyncMock()), \
|
||||
patch("app.main.start_scheduler"), \
|
||||
patch("app.main.stop_scheduler"), \
|
||||
patch("app.main.reconcile_orphan_runs", new=AsyncMock()) as reconcile:
|
||||
async with lifespan(fastapi_app):
|
||||
pass
|
||||
|
||||
reconcile.assert_awaited_once()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Tests for the HTTP probe used by deep-scan service identification."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.http_probe import (
|
||||
_MAX_BODY_BYTES,
|
||||
_extract_title,
|
||||
probe_open_ports,
|
||||
probe_port,
|
||||
@@ -15,6 +16,39 @@ def _response(text: str = "", headers: dict | None = None, status: int = 200) ->
|
||||
return httpx.Response(status_code=status, text=text, headers=headers or {})
|
||||
|
||||
|
||||
class _TransportClient:
|
||||
"""
|
||||
Patch target for httpx.AsyncClient that routes through a MockTransport.
|
||||
|
||||
Real client, fake network: the probe exercises the genuine httpx request
|
||||
path (including .stream() and aiter_bytes()) instead of a mock that would
|
||||
happily accept any call shape. `requests` records what was actually sent.
|
||||
"""
|
||||
|
||||
def __init__(self, handler):
|
||||
self._handler = handler
|
||||
# Bound before patching, so building the real client here does not
|
||||
# recurse back into this stand-in.
|
||||
self._real = httpx.AsyncClient
|
||||
self.kwargs: list[dict] = []
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
def _record(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
return self._handler(request)
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
self.kwargs.append(dict(kwargs))
|
||||
kwargs.pop("verify", None)
|
||||
return self._real(transport=httpx.MockTransport(self._record), **kwargs)
|
||||
|
||||
|
||||
def _patch_client(handler) -> tuple:
|
||||
"""Return (context manager, factory) patching http_probe's AsyncClient."""
|
||||
factory = _TransportClient(handler)
|
||||
return patch("app.services.http_probe.httpx.AsyncClient", factory), factory
|
||||
|
||||
|
||||
# ── _extract_title ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_extract_title_basic():
|
||||
@@ -37,15 +71,18 @@ def test_extract_title_case_insensitive():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_reads_title():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("<title>Jellyfin</title>"))):
|
||||
ctx, _ = _patch_client(lambda req: _response("<title>Jellyfin</title>"))
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8096)
|
||||
assert result == {"title": "Jellyfin", "headers": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_reads_headers():
|
||||
resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)):
|
||||
ctx, _ = _patch_client(
|
||||
lambda req: _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
|
||||
)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 3000)
|
||||
assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
|
||||
|
||||
@@ -53,30 +90,33 @@ async def test_probe_port_reads_headers():
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_falls_back_to_http():
|
||||
# https raises, http succeeds
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_get(self, url, **kw):
|
||||
calls["n"] += 1
|
||||
if url.startswith("https"):
|
||||
def handler(request):
|
||||
if str(request.url).startswith("https"):
|
||||
raise httpx.ConnectError("tls fail")
|
||||
return _response("<title>HTTP App</title>")
|
||||
|
||||
with patch("httpx.AsyncClient.get", new=fake_get):
|
||||
ctx, factory = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result["title"] == "HTTP App"
|
||||
assert calls["n"] == 2 # tried https then http
|
||||
assert len(factory.requests) == 2 # tried https then http
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_no_signal_returns_none():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))):
|
||||
ctx, _ = _patch_client(lambda req: _response(""))
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_timeout_returns_none():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))):
|
||||
def handler(request):
|
||||
raise httpx.TimeoutException("slow")
|
||||
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result is None
|
||||
|
||||
@@ -84,33 +124,101 @@ async def test_probe_port_timeout_returns_none():
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_skips_non_http_ports():
|
||||
# SSH should never trigger an HTTP request
|
||||
get = AsyncMock()
|
||||
with patch("httpx.AsyncClient.get", new=get):
|
||||
ctx, factory = _patch_client(lambda req: _response("<title>nope</title>"))
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 22)
|
||||
assert result is None
|
||||
get.assert_not_called()
|
||||
assert factory.requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_verify_tls_flag_passed():
|
||||
with patch("app.services.http_probe.httpx.AsyncClient") as client_cls:
|
||||
instance = client_cls.return_value.__aenter__.return_value
|
||||
instance.get = AsyncMock(return_value=_response("<title>X</title>"))
|
||||
ctx, factory = _patch_client(lambda req: _response("<title>X</title>"))
|
||||
with ctx:
|
||||
await probe_port("10.0.0.5", 8443, verify_tls=True)
|
||||
assert client_cls.call_args.kwargs["verify"] is True
|
||||
assert factory.kwargs[0]["verify"] is True
|
||||
|
||||
|
||||
# ── endless bodies (issue #375) ─────────────────────────────────────────────
|
||||
|
||||
_CHUNK_SIZE = 16 * 1024
|
||||
|
||||
|
||||
def _endless_body(counter: dict, head: bytes = b""):
|
||||
"""
|
||||
A body that never ends and declares no Content-Length. Bounded at 512
|
||||
chunks so a regression fails the test instead of hanging the suite.
|
||||
"""
|
||||
|
||||
async def gen():
|
||||
if head:
|
||||
counter["bytes"] += len(head)
|
||||
yield head
|
||||
for _ in range(512):
|
||||
counter["bytes"] += _CHUNK_SIZE
|
||||
yield b"\0" * _CHUNK_SIZE
|
||||
|
||||
return gen()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_caps_the_download_of_an_endless_body():
|
||||
# _MAX_BODY_BYTES caps the download, not just the <title> scan: the body is
|
||||
# streamed and the connection dropped once we hold enough. Allow one chunk
|
||||
# of overshoot — the read stops on a chunk boundary.
|
||||
counter = {"bytes": 0}
|
||||
|
||||
def handler(request):
|
||||
# Plain HTTP only, like the reproducer, so the counter covers one probe.
|
||||
if str(request.url).startswith("https"):
|
||||
raise httpx.ConnectError("no tls")
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
content=_endless_body(counter),
|
||||
)
|
||||
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8095)
|
||||
assert result is None # null bytes carry no title and no signal header
|
||||
assert counter["bytes"] <= _MAX_BODY_BYTES + _CHUNK_SIZE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_still_reads_a_title_from_an_endless_body():
|
||||
# Stopping early must not cost us the signal: a <title> in the first chunk
|
||||
# is still found even though the rest of the stream is abandoned.
|
||||
counter = {"bytes": 0}
|
||||
|
||||
def handler(request):
|
||||
if str(request.url).startswith("https"):
|
||||
raise httpx.ConnectError("no tls")
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/html"},
|
||||
content=_endless_body(counter, head=b"<html><title>Jellyfin</title>"),
|
||||
)
|
||||
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8096)
|
||||
assert result["title"] == "Jellyfin"
|
||||
assert counter["bytes"] <= _MAX_BODY_BYTES + _CHUNK_SIZE
|
||||
|
||||
|
||||
# ── probe_open_ports ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_open_ports_enriches_each_port():
|
||||
async def fake_get(self, url, **kw):
|
||||
if ":8096" in url:
|
||||
def handler(request):
|
||||
if ":8096" in str(request.url):
|
||||
return _response("<title>Jellyfin</title>")
|
||||
return _response("")
|
||||
|
||||
ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}]
|
||||
with patch("httpx.AsyncClient.get", new=fake_get):
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_open_ports("10.0.0.5", ports)
|
||||
|
||||
by_port = {p["port"]: p for p in result}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for the in-process canvas-import job registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import import_jobs
|
||||
from app.services.import_jobs import (
|
||||
create_job,
|
||||
fail_job,
|
||||
finish_job,
|
||||
get_job,
|
||||
reset_jobs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean() -> None:
|
||||
reset_jobs()
|
||||
|
||||
|
||||
def test_create_job_starts_running_with_unique_id() -> None:
|
||||
a = create_job()
|
||||
b = create_job()
|
||||
assert a.id != b.id
|
||||
assert a.status == "running"
|
||||
assert a.result is None
|
||||
assert a.error is None
|
||||
|
||||
|
||||
def test_get_job_returns_the_registered_job() -> None:
|
||||
job = create_job()
|
||||
assert get_job(job.id) is job
|
||||
|
||||
|
||||
def test_get_unknown_job_returns_none() -> None:
|
||||
assert get_job("nope") is None
|
||||
|
||||
|
||||
def test_finish_job_records_the_payload() -> None:
|
||||
job = create_job()
|
||||
finish_job(job.id, {"device_count": 3})
|
||||
stored = get_job(job.id)
|
||||
assert stored is not None
|
||||
assert stored.status == "done"
|
||||
assert stored.result == {"device_count": 3}
|
||||
assert stored.finished_at is not None
|
||||
|
||||
|
||||
def test_fail_job_records_message_and_status() -> None:
|
||||
job = create_job()
|
||||
fail_job(job.id, "broker unreachable", 502)
|
||||
stored = get_job(job.id)
|
||||
assert stored is not None
|
||||
assert stored.status == "error"
|
||||
assert stored.error == "broker unreachable"
|
||||
assert stored.error_status == 502
|
||||
|
||||
|
||||
def test_finish_and_fail_are_noops_for_unknown_ids() -> None:
|
||||
finish_job("gone", {"device_count": 0})
|
||||
fail_job("gone", "boom", 500)
|
||||
assert get_job("gone") is None
|
||||
|
||||
|
||||
def test_finished_jobs_expire_after_the_ttl(monkeypatch) -> None:
|
||||
clock = {"now": 0.0}
|
||||
monkeypatch.setattr(import_jobs.time, "monotonic", lambda: clock["now"])
|
||||
|
||||
job = create_job()
|
||||
finish_job(job.id, {"device_count": 1})
|
||||
|
||||
clock["now"] = import_jobs.JOB_TTL_SECONDS + 1
|
||||
assert get_job(job.id) is None
|
||||
|
||||
|
||||
def test_running_jobs_never_expire(monkeypatch) -> None:
|
||||
"""A slow mesh must not have its job purged out from under it."""
|
||||
clock = {"now": 0.0}
|
||||
monkeypatch.setattr(import_jobs.time, "monotonic", lambda: clock["now"])
|
||||
|
||||
job = create_job()
|
||||
clock["now"] = import_jobs.JOB_TTL_SECONDS * 10
|
||||
assert get_job(job.id) is not None
|
||||
|
||||
|
||||
def test_reset_jobs_clears_everything() -> None:
|
||||
job = create_job()
|
||||
reset_jobs()
|
||||
assert get_job(job.id) is None
|
||||
@@ -17,8 +17,11 @@ from app.services.inventory_sync import (
|
||||
backfill_node_devices,
|
||||
changed_facts,
|
||||
find_device_for,
|
||||
hydrated_node,
|
||||
link_facts,
|
||||
merge_properties,
|
||||
merge_services,
|
||||
seed_node_views,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,6 +85,49 @@ class TestMergeRules:
|
||||
assert out[1]["port"] == 80
|
||||
|
||||
|
||||
def test_a_scan_never_repaints_an_icon_the_user_picked(self):
|
||||
"""Every "Scan network" used to overwrite a hand-picked brand icon."""
|
||||
base = [
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http",
|
||||
"icon": "brand:pihole", "category": "network"},
|
||||
]
|
||||
# What fingerprint_ports returns for the same port: its own guess.
|
||||
incoming = [
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http",
|
||||
"icon": "Globe", "category": "web"},
|
||||
]
|
||||
out = merge_services(base, incoming, discovered=True)
|
||||
assert len(out) == 1
|
||||
assert out[0]["icon"] == "brand:pihole"
|
||||
assert out[0]["category"] == "network"
|
||||
|
||||
def test_a_scan_still_fills_an_icon_the_service_never_had(self):
|
||||
out = merge_services(
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http",
|
||||
"icon": "Globe", "category": "web"}],
|
||||
discovered=True,
|
||||
)
|
||||
assert out[0]["icon"] == "Globe"
|
||||
assert out[0]["category"] == "web"
|
||||
|
||||
def test_a_user_edit_still_changes_the_icon(self):
|
||||
"""The guard is for scanner output only — an edit is an edit."""
|
||||
out = merge_services(
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": "Globe"}],
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": "brand:pihole"}],
|
||||
)
|
||||
assert out[0]["icon"] == "brand:pihole"
|
||||
|
||||
def test_services_keep_an_established_icon_when_incoming_has_none(self):
|
||||
"""A blank field is silence, not a reset — mirrors merge_properties."""
|
||||
out = merge_services(
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": "brand:pihole"}],
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": None}],
|
||||
)
|
||||
assert out[0]["icon"] == "brand:pihole"
|
||||
|
||||
|
||||
class TestChangedFacts:
|
||||
"""What a save is allowed to write: its edit, not its whole snapshot."""
|
||||
|
||||
@@ -178,6 +224,60 @@ class TestFindDeviceFor:
|
||||
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee=None)
|
||||
assert found is not None and found.id == "d-hidden"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_an_ieee_written_in_another_case(self, db_session):
|
||||
"""One radio, one row: `ieee_address` is UNIQUE, so a case-sensitive
|
||||
match here would mint a second row the index then refuses."""
|
||||
db_session.add(InventoryDevice(id="d-1", ieee_address="0x00124b0022334455"))
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(
|
||||
db_session, ip=None, mac=None, ieee="0x00124B0022334455"
|
||||
)
|
||||
assert found is not None and found.id == "d-1"
|
||||
|
||||
|
||||
class TestIeeeCollisions:
|
||||
"""`device_inventory.ieee_address` is UNIQUE — a merge must never duplicate it."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_address_another_row_owns_is_left_where_it_is(self, db_session):
|
||||
"""The node is already linked, so identity is settled — the IEEE is not
|
||||
a reason to move it, and writing it here would violate the index."""
|
||||
design = await _design(db_session)
|
||||
db_session.add_all([
|
||||
InventoryDevice(id="d-radio", ieee_address="0xAAA"),
|
||||
InventoryDevice(id="d-host", ip="10.0.0.5"),
|
||||
])
|
||||
node = _node(design, device_id="d-host")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.id == "d-host"
|
||||
assert device.ieee_address is None
|
||||
radio = await db_session.get(InventoryDevice, "d-radio")
|
||||
assert radio is not None and radio.ieee_address == "0xAAA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_row_that_already_holds_it_keeps_writing_it(self, db_session):
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", ieee_address="0xAAA"))
|
||||
node = _node(design)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.ieee_address == "0xAAA"
|
||||
|
||||
|
||||
# --- the backfill -----------------------------------------------------------
|
||||
|
||||
@@ -238,7 +338,7 @@ class TestBackfill:
|
||||
db_session.add(_node(design))
|
||||
await db_session.commit()
|
||||
|
||||
assert await backfill_node_devices(db_session) == {"linked": 0, "created": 0, "merged": 0}
|
||||
assert await backfill_node_devices(db_session) == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_links_a_node_to_its_existing_row(self, db_session):
|
||||
@@ -251,7 +351,7 @@ class TestBackfill:
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 0, "merged": 1}
|
||||
assert stats == {"linked": 1, "created": 0, "merged": 1, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None and node.device_id == "d-1"
|
||||
|
||||
@@ -266,7 +366,7 @@ class TestBackfill:
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0}
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
@@ -330,6 +430,133 @@ class TestBackfill:
|
||||
assert stats["linked"] == 0
|
||||
assert (await db_session.execute(select(InventoryDevice))).scalars().all() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_carries_over_timestamps_stored_as_text(self, db_session):
|
||||
"""The legacy columns are read with raw SQL, so SQLite hands the DATETIME
|
||||
ones back as strings. Writing one straight into the row raises
|
||||
``TypeError: SQLite DateTime type only accepts Python datetime and date
|
||||
objects`` — which used to abort the whole backfill (issue #347)."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
node_id = await self._legacy_node(
|
||||
db_session,
|
||||
design,
|
||||
ip="10.0.0.5",
|
||||
label="Dockerhost",
|
||||
status="online",
|
||||
last_seen="2026-08-15 04:10:22.123456",
|
||||
last_scan="2026-08-15 04:00:00",
|
||||
)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.last_seen == datetime(2026, 8, 15, 4, 10, 22, 123456)
|
||||
assert device.last_scan == datetime(2026, 8, 15, 4, 0, 0)
|
||||
assert device.status_live == "online"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stamp_it_cannot_parse_costs_only_the_stamp(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.5", last_seen="not a date")
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
device = (await db_session.execute(select(InventoryDevice))).scalars().one()
|
||||
assert device.last_seen is None
|
||||
assert device.ip == "10.0.0.5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_unwritable_node_does_not_cost_the_others_their_link(self, db_session, monkeypatch):
|
||||
"""Whatever a single node raises — not only a constraint violation — the
|
||||
rest of the canvas still gets linked."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.5", label="good")
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.6", label="bad")
|
||||
|
||||
import app.services.inventory_sync as module
|
||||
|
||||
real = module.link_facts
|
||||
|
||||
async def link_facts(db, node, facts, **kwargs):
|
||||
if facts.get("ip") == "10.0.0.6":
|
||||
raise TypeError("SQLite DateTime type only accepts Python datetime")
|
||||
return await real(db, node, facts, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "link_facts", link_facts)
|
||||
|
||||
stats = await module.backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
assert stats["skipped"] == 1
|
||||
devices = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert [d.ip for d in devices] == ["10.0.0.5"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fills_a_blank_row_a_stuck_canvas_save_created(self, db_session):
|
||||
"""A save made while an earlier migration was stuck linked the node to a
|
||||
row minted from a blank UI. Those facts only exist in the legacy columns,
|
||||
and the drop is about to remove them — so fill the row before it does."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-blank", ip="", label="Dockerhost", services=[]))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, label="Dockerhost", ip="192.168.0.42", hostname="clara.lan",
|
||||
notes="in the cupboard", services=[{"port": 443, "protocol": "tcp", "service_name": "https"}],
|
||||
)
|
||||
node = await db_session.get(Node, node_id)
|
||||
node.device_id = "d-blank"
|
||||
await db_session.commit()
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
device = await db_session.get(InventoryDevice, "d-blank")
|
||||
assert device is not None
|
||||
assert device.ip == "192.168.0.42"
|
||||
assert device.hostname == "clara.lan"
|
||||
assert device.notes == "in the cupboard"
|
||||
assert [s["service_name"] for s in device.services] == ["https"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_a_linked_row_that_already_has_the_facts_alone(self, db_session):
|
||||
"""The mirror of the above: a row a user has since edited is not reverted
|
||||
to what the node's abandoned columns still say."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(
|
||||
id="d-1", ip="192.168.0.99", hostname="renamed.lan",
|
||||
label="Dockerhost", type="docker_host",
|
||||
))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, ip="192.168.0.42", hostname="clara.lan", label="Dockerhost"
|
||||
)
|
||||
node = await db_session.get(Node, node_id)
|
||||
node.device_id = "d-1"
|
||||
await db_session.commit()
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 0
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
assert device is not None
|
||||
assert device.ip == "192.168.0.99"
|
||||
assert device.hostname == "renamed.lan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_a_no_op_on_a_second_run(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
@@ -342,10 +569,65 @@ class TestBackfill:
|
||||
await db_session.commit()
|
||||
|
||||
assert first["linked"] == 1
|
||||
assert second == {"linked": 0, "created": 0, "merged": 0}
|
||||
assert second == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_canvas_keeps_showing_what_it_showed(self, db_session):
|
||||
"""The upgrade must not redraw a canvas.
|
||||
|
||||
Two canvases drew the same host with different service lists, and the
|
||||
scanner's row for it holds a third the user never put on either. They
|
||||
converge on one row — so each node keeps a view of the subset it drew,
|
||||
and the scanner's guess appears on neither.
|
||||
"""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
ssh = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||
https = {"port": 443, "protocol": "tcp", "service_name": "https"}
|
||||
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", services=[kuma]))
|
||||
await db_session.commit()
|
||||
|
||||
one, two = await _design(db_session, "Net"), await _design(db_session, "Rack")
|
||||
a = await self._legacy_node(db_session, one, ip="10.0.0.5", services=[ssh])
|
||||
b = await self._legacy_node(db_session, two, ip="10.0.0.5", services=[ssh, https])
|
||||
|
||||
await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
assert {s["service_name"] for s in device.services} == {"Uptime Kuma", "ssh", "https"}
|
||||
drawn = {}
|
||||
for node_id in (a, b):
|
||||
node = await db_session.get(Node, node_id)
|
||||
payload = hydrated_node(node, device)
|
||||
drawn[node_id] = [s["service_name"] for s in payload["services"] if s.get("visible", True)]
|
||||
assert drawn[a] == ["ssh"]
|
||||
assert drawn[b] == ["ssh", "https"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_node_that_drew_no_service_keeps_drawing_none(self, db_session):
|
||||
"""An empty legacy list is an answer: that canvas showed no services."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1", ip="10.0.0.5",
|
||||
services=[{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design = await _design(db_session)
|
||||
node_id = await self._legacy_node(db_session, design, ip="10.0.0.5", services=[])
|
||||
|
||||
await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
node = await db_session.get(Node, node_id)
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
payload = hydrated_node(node, device)
|
||||
assert [s.get("visible", True) for s in payload["services"]] == [False]
|
||||
|
||||
|
||||
# --- routes -----------------------------------------------------------------
|
||||
|
||||
@@ -798,3 +1080,374 @@ class TestRoutesKeepTheLinkInStep:
|
||||
assert res.status_code == 200
|
||||
node = await db_session.get(Node, res.json()["node_id"])
|
||||
assert node is not None and node.device_id == "d-1"
|
||||
|
||||
|
||||
class TestPerNodeView:
|
||||
"""Order and visibility belong to the node, the facts to the row.
|
||||
|
||||
The same device drawn on two canvases is one inventory row, so without a
|
||||
per-node view every canvas showing it inherits every service a scan ever
|
||||
fingerprinted and every property any other canvas added.
|
||||
"""
|
||||
|
||||
async def _node_on(self, client, headers, design_id: str, **payload) -> dict:
|
||||
body = {"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_id, "force": True}
|
||||
body.update(payload)
|
||||
res = await client.post("/api/v1/nodes", json=body, headers=body.pop("headers", None) or headers)
|
||||
assert res.status_code == 201
|
||||
return res.json()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_new_node_shows_what_the_row_already_holds(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""An empty payload list means "I have nothing to say", not "hide it all"."""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design = await _design(db_session)
|
||||
|
||||
node = await self._node_on(client, headers, design)
|
||||
assert [s["service_name"] for s in node["services"]] == ["ssh"]
|
||||
assert all(s.get("visible", True) for s in node["services"])
|
||||
assert node["properties"][0]["visible"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hiding_a_service_on_one_node_leaves_the_other_alone(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
services=[
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"},
|
||||
],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
one, two = await _design(db_session, "Net"), await _design(db_session, "Rack")
|
||||
node_a = await self._node_on(client, headers, one)
|
||||
node_b = await self._node_on(client, headers, two)
|
||||
|
||||
hidden = [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma", "visible": False},
|
||||
]
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_a['id']}", json={"services": hidden}, headers=headers
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert [(s["service_name"], s.get("visible", True)) for s in res.json()["services"]] == [
|
||||
("ssh", True), ("Uptime Kuma", False),
|
||||
]
|
||||
|
||||
other = (await client.get(f"/api/v1/nodes/{node_b['id']}", headers=headers)).json()
|
||||
assert [s.get("visible", True) for s in other["services"]] == [True, True]
|
||||
# The service itself is untouched — hiding is not deleting.
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert len(device.services) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_order_is_per_node_too(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
properties=[
|
||||
{"key": "Rack", "value": "A1", "icon": None, "visible": True},
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
one, two = await _design(db_session, "Net"), await _design(db_session, "Rack")
|
||||
node_a = await self._node_on(client, headers, one)
|
||||
node_b = await self._node_on(client, headers, two)
|
||||
|
||||
flipped = [
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": True},
|
||||
{"key": "Rack", "value": "A1", "icon": None, "visible": True},
|
||||
]
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_a['id']}", json={"properties": flipped}, headers=headers
|
||||
)
|
||||
assert [p["key"] for p in res.json()["properties"]] == ["Owner", "Rack"]
|
||||
|
||||
other = (await client.get(f"/api/v1/nodes/{node_b['id']}", headers=headers)).json()
|
||||
assert [p["key"] for p in other["properties"]] == ["Rack", "Owner"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_service_the_row_gains_later_stays_off_the_canvas(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""The leak this column exists to stop: a scan must not redraw every canvas."""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1", ip="10.0.0.5",
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design = await _design(db_session)
|
||||
node = await self._node_on(client, headers, design)
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
device.services = [
|
||||
*device.services,
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"},
|
||||
]
|
||||
await db_session.commit()
|
||||
|
||||
after = (await client.get(f"/api/v1/nodes/{node['id']}", headers=headers)).json()
|
||||
assert [(s["service_name"], s.get("visible", True)) for s in after["services"]] == [
|
||||
("ssh", True), ("Uptime Kuma", False),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removing_a_service_removes_it_from_the_device(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Delete is device-wide — hiding is what a single canvas does."""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1", ip="10.0.0.5",
|
||||
services=[
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"},
|
||||
],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
one, two = await _design(db_session, "Net"), await _design(db_session, "Rack")
|
||||
node_a = await self._node_on(client, headers, one)
|
||||
node_b = await self._node_on(client, headers, two)
|
||||
|
||||
await client.patch(
|
||||
f"/api/v1/nodes/{node_a['id']}",
|
||||
json={"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]},
|
||||
headers=headers,
|
||||
)
|
||||
other = (await client.get(f"/api/v1/nodes/{node_b['id']}", headers=headers)).json()
|
||||
assert [s["service_name"] for s in other["services"]] == ["ssh"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_canvas_save_records_the_view_even_when_no_fact_changed(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""`changed_facts` guards the shared row, not the node's own view.
|
||||
|
||||
A canvas save reporting no edited fact is exactly what hiding a service
|
||||
looks like from the row's side — nothing about the device changed. The
|
||||
view still has to land, or the toggle would not survive the save.
|
||||
"""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1", ip="10.0.0.5",
|
||||
services=[
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"},
|
||||
],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design = await _design(db_session)
|
||||
node = await self._node_on(client, headers, design)
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design,
|
||||
"nodes": [{
|
||||
"id": node["id"], "type": "nas", "label": "NAS", "ip": "10.0.0.5",
|
||||
"device_id": "d-1", "changed_facts": [], "pos_x": 10.0, "pos_y": 20.0,
|
||||
"services": [
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma", "visible": False},
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
],
|
||||
}],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
loaded = (await client.get(f"/api/v1/canvas?design_id={design}", headers=headers)).json()
|
||||
assert [(s["service_name"], s.get("visible", True)) for s in loaded["nodes"][0]["services"]] == [
|
||||
("Uptime Kuma", False), ("ssh", True),
|
||||
]
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert [s["service_name"] for s in device.services] == ["ssh", "Uptime Kuma"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeding_freezes_what_a_pre_upgrade_node_showed(self, db_session):
|
||||
"""`display_view` arrives NULL on every existing node; the seed fills it."""
|
||||
design = await _design(db_session)
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1", ip="10.0.0.5",
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
)
|
||||
)
|
||||
node = _node(design, device_id="d-1")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
assert await seed_node_views(db_session) == 1
|
||||
await db_session.commit()
|
||||
assert node.display_view == {
|
||||
"services": [{"key": "22|tcp|ssh", "visible": True}],
|
||||
"properties": [],
|
||||
}
|
||||
# Idempotent: a second boot finds nothing without a view.
|
||||
assert await seed_node_views(db_session) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_node_whose_device_was_deleted_does_not_keep_the_seed_alive(self, db_session):
|
||||
"""Deleting a device leaves `nodes.device_id` dangling — foreign keys are off.
|
||||
|
||||
Such a node can be seeded from nothing, so it must not keep matching the
|
||||
query: it would re-open the pre-upgrade backup on every single boot and
|
||||
log a recovery that recovers nothing.
|
||||
"""
|
||||
design = await _design(db_session)
|
||||
db_session.add(_node(design, device_id="d-gone"))
|
||||
await db_session.commit()
|
||||
|
||||
reads = []
|
||||
|
||||
def drawn():
|
||||
reads.append(1)
|
||||
return {}
|
||||
|
||||
assert await seed_node_views(db_session, drawn=drawn) == 0
|
||||
assert reads == [] # the backup was never opened
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeding_leaves_furniture_alone(self, db_session):
|
||||
design = await _design(db_session)
|
||||
node = _node(design, type="groupRect")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
assert await seed_node_views(db_session) == 0
|
||||
assert node.display_view is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_view_matching_nothing_left_on_the_row_shows_the_row(self, db_session):
|
||||
"""The row was replaced wholesale under the node — don't draw a blank node.
|
||||
|
||||
A view whose every key is gone tells us nothing about the list that
|
||||
replaced it, and hiding all of it leaves a node showing no service while
|
||||
"Show services" is on (#347). An *empty* view is a real answer and still
|
||||
hides everything — that case is covered below.
|
||||
"""
|
||||
device = InventoryDevice(
|
||||
id="d-1",
|
||||
services=[
|
||||
{"port": 8080, "protocol": "tcp", "service_name": "HTTP Alt"},
|
||||
{"port": 5001, "protocol": "tcp", "service_name": "Synology DSM HTTPS"},
|
||||
],
|
||||
)
|
||||
node = _node(
|
||||
await _design(db_session),
|
||||
device_id="d-1",
|
||||
display_view={"services": [{"key": "9000|tcp|portainer", "visible": True}]},
|
||||
)
|
||||
assert [s["service_name"] for s in hydrated_node(node, device)["services"]] == [
|
||||
"HTTP Alt", "Synology DSM HTTPS",
|
||||
]
|
||||
assert all(s.get("visible", True) for s in hydrated_node(node, device)["services"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_view_still_hides_everything(self, db_session):
|
||||
""""This canvas draws none of them" is an answer, not a missing view."""
|
||||
device = InventoryDevice(
|
||||
id="d-1", services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
|
||||
)
|
||||
node = _node(await _design(db_session), device_id="d-1", display_view={"services": []})
|
||||
assert hydrated_node(node, device)["services"] == [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh", "visible": False}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeding_keeps_a_property_added_after_the_backup(self, db_session):
|
||||
"""The backup is 3.2.0-era: it cannot know a property added since.
|
||||
|
||||
Adding one while on 3.3.0-3.3.2 wrote it to the row, and every canvas
|
||||
drew it. Seeding strictly from the backup would take it off all of them
|
||||
at once (#347), so it is appended visible after what the backup places.
|
||||
"""
|
||||
design = await _design(db_session)
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1", ip="10.0.0.5",
|
||||
properties=[
|
||||
{"key": "Rack", "value": "A", "icon": None, "visible": True},
|
||||
{"key": "Ports", "value": "8", "icon": None, "visible": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
node = _node(design, device_id="d-1")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
# The backup only knew "Rack", and this canvas had it hidden.
|
||||
drawn = {node.id: {
|
||||
"services": [],
|
||||
"properties": [{"key": "Rack", "value": "A", "icon": None, "visible": False}],
|
||||
}}
|
||||
assert await seed_node_views(db_session, drawn=lambda: drawn) == 1
|
||||
await db_session.commit()
|
||||
|
||||
assert node.display_view["properties"] == [
|
||||
{"key": "rack", "visible": False}, # recovered, still hidden here
|
||||
{"key": "ports", "visible": True}, # added since the backup, kept
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeding_still_holds_back_a_service_found_since_the_backup(self, db_session):
|
||||
"""Services are not treated that way: a scan's find stays off the canvas."""
|
||||
design = await _design(db_session)
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1", ip="10.0.0.5",
|
||||
services=[
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 5001, "protocol": "tcp", "service_name": "Synology DSM HTTPS"},
|
||||
],
|
||||
)
|
||||
)
|
||||
node = _node(design, device_id="d-1")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
drawn = {node.id: {
|
||||
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
"properties": [],
|
||||
}}
|
||||
assert await seed_node_views(db_session, drawn=lambda: drawn) == 1
|
||||
await db_session.commit()
|
||||
|
||||
assert node.display_view["services"] == [{"key": "22|tcp|ssh", "visible": True}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_node_without_a_view_still_shows_everything(self, db_session):
|
||||
"""No view — furniture, or a node an older version linked — is not "hide all"."""
|
||||
device = InventoryDevice(
|
||||
id="d-1", services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
|
||||
)
|
||||
node = _node(await _design(db_session), device_id="d-1")
|
||||
assert hydrated_node(node, device)["services"] == [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||
]
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.mqtt_common import (
|
||||
_RESPONSE_TIMEOUT,
|
||||
_build_tls_context,
|
||||
_sanitize_mqtt_error,
|
||||
request_response,
|
||||
@@ -181,3 +184,100 @@ async def test_test_connection_failure() -> None:
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
with pytest.raises(ConnectionError):
|
||||
await _test_connection("bad", 1883)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response timeout — configurable via MQTT_RESPONSE_TIMEOUT (issue #380)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SilentClient:
|
||||
"""An MQTT client that connects but never delivers the response message."""
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
async def subscribe(self, *_a, **_kw) -> None:
|
||||
pass
|
||||
|
||||
async def publish(self, *_a, **_kw) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def messages(self):
|
||||
async def _never():
|
||||
await asyncio.Event().wait()
|
||||
yield # pragma: no cover
|
||||
|
||||
return _never()
|
||||
|
||||
|
||||
class _NeverMqttError(Exception):
|
||||
"""Stand-in for aiomqtt.MqttError that no test path actually raises."""
|
||||
|
||||
|
||||
async def _round_trip(**kwargs):
|
||||
return await request_response(
|
||||
mqtt_host="host",
|
||||
mqtt_port=1883,
|
||||
request_topic="req",
|
||||
response_topic="res",
|
||||
request_payload={},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_uses_settings_timeout(monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "mqtt_response_timeout", 0.01)
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
|
||||
with pytest.raises(TimeoutError) as ei:
|
||||
await _round_trip()
|
||||
|
||||
msg = str(ei.value)
|
||||
assert "0.01s" in msg
|
||||
assert "MQTT_RESPONSE_TIMEOUT" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_explicit_timeout_overrides_settings(monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "mqtt_response_timeout", 999)
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
|
||||
with pytest.raises(TimeoutError) as ei:
|
||||
await _round_trip(response_timeout=0.01)
|
||||
|
||||
assert "0.01s" in str(ei.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_non_positive_setting_falls_back(monkeypatch) -> None:
|
||||
"""A misconfigured 0 must not mean 'give up immediately'."""
|
||||
monkeypatch.setattr(settings, "mqtt_response_timeout", 0)
|
||||
captured: dict[str, float] = {}
|
||||
|
||||
async def _fake_wait_for(awaitable, timeout):
|
||||
captured["timeout"] = timeout
|
||||
awaitable.close()
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
with (
|
||||
patch("app.services.mqtt_common.asyncio.wait_for", _fake_wait_for),
|
||||
pytest.raises(TimeoutError),
|
||||
):
|
||||
await _round_trip()
|
||||
|
||||
assert captured["timeout"] == _RESPONSE_TIMEOUT
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -750,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."""
|
||||
@@ -988,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for status_checker service: each check method."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.status_checker import (
|
||||
@@ -13,16 +14,31 @@ from app.services.status_checker import (
|
||||
)
|
||||
|
||||
|
||||
class _TransportClient:
|
||||
"""
|
||||
Patch target for httpx.AsyncClient that routes through a MockTransport.
|
||||
|
||||
Real client, fake network: _http_get exercises the genuine httpx request
|
||||
path (including .stream()) instead of a mock that would happily accept any
|
||||
call shape.
|
||||
"""
|
||||
|
||||
def __init__(self, handler):
|
||||
self._handler = handler
|
||||
# Bound before patching, so building the real client here does not
|
||||
# recurse back into this stand-in.
|
||||
self._real = httpx.AsyncClient
|
||||
self.kwargs = []
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
self.kwargs.append(dict(kwargs))
|
||||
kwargs.pop("verify", None)
|
||||
return self._real(transport=httpx.MockTransport(self._handler), **kwargs)
|
||||
|
||||
|
||||
def _mock_httpx_client(status_code):
|
||||
"""Build a stand-in for httpx.AsyncClient whose GET returns status_code."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=client)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
return MagicMock(return_value=ctx)
|
||||
return _TransportClient(lambda request: httpx.Response(status_code))
|
||||
|
||||
# --- check_node dispatcher ---
|
||||
|
||||
@@ -554,6 +570,43 @@ async def test_check_services_empty_list():
|
||||
|
||||
# --- _http_get status-code interpretation (real primitive, mocked transport) ---
|
||||
|
||||
# --- Regression: an endless response body must not be buffered (issue #375) ---
|
||||
|
||||
_CHUNK = b"\0" * 65536
|
||||
|
||||
|
||||
def _endless_body(counter: dict):
|
||||
"""
|
||||
A body that never ends and declares no Content-Length — the Freebox
|
||||
bandwidth-test endpoint from issue #375. Bounded at 512 chunks (32 MiB) so
|
||||
a regression fails the test instead of hanging the suite forever.
|
||||
"""
|
||||
|
||||
async def gen():
|
||||
for _ in range(512):
|
||||
counter["chunks"] += 1
|
||||
yield _CHUNK
|
||||
|
||||
return gen()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_get_does_not_read_the_body():
|
||||
# _http_get only needs the status line. Buffering the body of an endless
|
||||
# stream is what OOM-killed the backend, so assert not one byte is pulled.
|
||||
counter = {"chunks": 0}
|
||||
factory = _TransportClient(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
content=_endless_body(counter),
|
||||
)
|
||||
)
|
||||
with patch("app.services.status_checker.httpx.AsyncClient", factory):
|
||||
assert await _http_get("http://192.168.1.254:8095/") is True
|
||||
assert counter["chunks"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_get_true_on_2xx():
|
||||
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(200)):
|
||||
|
||||
@@ -7,10 +7,34 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.services.import_jobs import reset_jobs
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_import_jobs() -> None:
|
||||
"""The canvas-import job registry is process-global; isolate each test."""
|
||||
reset_jobs()
|
||||
|
||||
|
||||
async def _start_canvas_import(
|
||||
client: AsyncClient, headers: dict, body: dict
|
||||
) -> str:
|
||||
"""POST /zigbee/import and return the job id.
|
||||
|
||||
The route is fire-and-poll: it answers 202 immediately and the fetch runs as
|
||||
a Starlette background task, which the ASGI transport completes before this
|
||||
await returns — so the job is already settled by the time we poll.
|
||||
"""
|
||||
res = await client.post("/api/v1/zigbee/import", json=body, headers=headers)
|
||||
assert res.status_code == 202, res.text
|
||||
job_id: str = res.json()["job_id"]
|
||||
assert res.json()["status"] == "running"
|
||||
return job_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zigbee/test-connection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -104,18 +128,18 @@ _SAMPLE_EDGES = [
|
||||
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"base_topic": "zigbee2mqtt",
|
||||
},
|
||||
headers=headers,
|
||||
job_id = await _start_canvas_import(
|
||||
client,
|
||||
headers,
|
||||
{"mqtt_host": "localhost", "mqtt_port": 1883, "base_topic": "zigbee2mqtt"},
|
||||
)
|
||||
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
body = res.json()
|
||||
assert body["status"] == "done"
|
||||
assert body["job_id"] == job_id
|
||||
data = body["result"]
|
||||
assert data["device_count"] == 2
|
||||
assert len(data["nodes"]) == 2
|
||||
assert len(data["edges"]) == 1
|
||||
@@ -123,22 +147,57 @@ async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||
assert coordinator["ieee_address"] == "0x00000000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_post_does_not_block_on_the_fetch(
|
||||
client: AsyncClient, headers: dict
|
||||
) -> None:
|
||||
"""The POST answers 202 without a payload — that is what keeps a slow mesh
|
||||
from outliving a reverse proxy's read timeout (issue #380)."""
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 202
|
||||
assert set(res.json()) == {"job_id", "status"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_job_unknown_id_returns_404(
|
||||
client: AsyncClient, headers: dict
|
||||
) -> None:
|
||||
res = await client.get("/api/v1/zigbee/import/no-such-job", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_job_requires_auth(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "localhost", "mqtt_port": 1883}
|
||||
)
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={
|
||||
await _start_canvas_import(
|
||||
client,
|
||||
headers,
|
||||
{
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"mqtt_username": "admin",
|
||||
"mqtt_password": "secret",
|
||||
"base_topic": "z2m",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
mock_fetch.assert_called_once_with(
|
||||
mqtt_host="localhost",
|
||||
mqtt_port=1883,
|
||||
@@ -154,11 +213,10 @@ async def test_import_with_credentials(client: AsyncClient, headers: dict) -> No
|
||||
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "bad-host", "mqtt_port": 1883}
|
||||
)
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
assert res.status_code == 502
|
||||
assert "broker unreachable" in res.json()["detail"]
|
||||
|
||||
@@ -167,23 +225,22 @@ async def test_import_connection_error_returns_502(client: AsyncClient, headers:
|
||||
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.side_effect = TimeoutError("timed out")
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "localhost", "mqtt_port": 1883}
|
||||
)
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
assert res.status_code == 504
|
||||
assert "timed out" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.side_effect = ValueError("malformed response")
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "localhost", "mqtt_port": 1883}
|
||||
)
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@@ -201,13 +258,12 @@ async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
|
||||
"""An empty Zigbee network (coordinator only) is a valid response."""
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "localhost", "mqtt_port": 1883}
|
||||
)
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
data = res.json()["result"]
|
||||
assert data["device_count"] == 0
|
||||
assert data["nodes"] == []
|
||||
assert data["edges"] == []
|
||||
@@ -227,16 +283,11 @@ async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> N
|
||||
async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={
|
||||
"mqtt_host": "broker.example.com",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_tls": True,
|
||||
},
|
||||
headers=headers,
|
||||
await _start_canvas_import(
|
||||
client,
|
||||
headers,
|
||||
{"mqtt_host": "broker.example.com", "mqtt_port": 8883, "mqtt_tls": True},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
kwargs = mock_fetch.call_args.kwargs
|
||||
assert kwargs["tls"] is True
|
||||
assert kwargs["tls_insecure"] is False
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -9,7 +10,9 @@ from unittest.mock import patch
|
||||
import aiomqtt # noqa: F401
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.zigbee_service import (
|
||||
_NETWORKMAP_TIMEOUT,
|
||||
_find_parent_router,
|
||||
_z2m_type_to_homelable,
|
||||
fetch_networkmap,
|
||||
@@ -433,6 +436,10 @@ async def test_test_mqtt_connection_failure() -> None:
|
||||
await _test_mqtt_connection("bad-host", 1883)
|
||||
|
||||
|
||||
class _NeverMqttError(Exception):
|
||||
"""Stand-in for aiomqtt.MqttError that no test path actually raises."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS context
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -571,3 +578,95 @@ async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> Non
|
||||
assert "hunter2" not in msg
|
||||
assert "admin" not in msg
|
||||
assert msg == "Authentication failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Networkmap response timeout — configurable via ZIGBEE_NETWORKMAP_TIMEOUT
|
||||
# (issue #380: a 200+ device mesh needs longer than the hard-coded 300 s)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SilentClient:
|
||||
"""An MQTT client that connects but never delivers the response message."""
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
async def subscribe(self, *_a, **_kw) -> None:
|
||||
pass
|
||||
|
||||
async def publish(self, *_a, **_kw) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def messages(self):
|
||||
async def _never():
|
||||
await asyncio.Event().wait()
|
||||
yield # pragma: no cover
|
||||
|
||||
return _never()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_networkmap_uses_settings_timeout(monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "zigbee_networkmap_timeout", 0.01)
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
|
||||
with pytest.raises(TimeoutError) as ei:
|
||||
await fetch_networkmap(
|
||||
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
|
||||
)
|
||||
|
||||
msg = str(ei.value)
|
||||
assert "0.01s" in msg
|
||||
assert "ZIGBEE_NETWORKMAP_TIMEOUT" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_networkmap_explicit_timeout_overrides_settings(monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "zigbee_networkmap_timeout", 999)
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
|
||||
with pytest.raises(TimeoutError) as ei:
|
||||
await fetch_networkmap(
|
||||
mqtt_host="host",
|
||||
mqtt_port=1883,
|
||||
base_topic="zigbee2mqtt",
|
||||
response_timeout=0.01,
|
||||
)
|
||||
|
||||
assert "0.01s" in str(ei.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_networkmap_non_positive_setting_falls_back(monkeypatch) -> None:
|
||||
"""A misconfigured 0 must not mean 'give up immediately'."""
|
||||
monkeypatch.setattr(settings, "zigbee_networkmap_timeout", 0)
|
||||
captured: dict[str, float] = {}
|
||||
|
||||
async def _fake_wait_for(awaitable, timeout):
|
||||
captured["timeout"] = timeout
|
||||
awaitable.close()
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
with (
|
||||
patch("app.services.zigbee_service.asyncio.wait_for", _fake_wait_for),
|
||||
pytest.raises(TimeoutError),
|
||||
):
|
||||
await fetch_networkmap(
|
||||
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
|
||||
)
|
||||
|
||||
assert captured["timeout"] == _NETWORKMAP_TIMEOUT
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Moving a zone's size out of the custom_colors blob into the real columns.
|
||||
|
||||
Every node type stored its size in `nodes.width` / `nodes.height` except
|
||||
`groupRect`, which kept it inside the style JSON. Get this backfill wrong and
|
||||
every zone a user ever drew comes back at the default 360x240, losing a layout
|
||||
they arranged by hand.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
import app.db.database as database
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
_DDL = (
|
||||
"CREATE TABLE nodes ("
|
||||
"id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
|
||||
"custom_colors JSON, width FLOAT, height FLOAT)"
|
||||
)
|
||||
|
||||
|
||||
async def _engine(tmp_path, rows):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'zones.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DDL)
|
||||
for node_id, node_type, colors, width, height in rows:
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, custom_colors, width, height) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(node_id, node_type, node_id, json.dumps(colors) if colors else None, width, height),
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
async def _sizes(engine) -> dict[str, tuple]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.exec_driver_sql("SELECT id, width, height FROM nodes")).fetchall()
|
||||
return {r[0]: (r[1], r[2]) for r in rows}
|
||||
|
||||
|
||||
async def _run(engine, monkeypatch) -> None:
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
await database._backfill_zone_size()
|
||||
|
||||
|
||||
async def test_moves_the_size_from_the_blob_to_the_columns(tmp_path, monkeypatch):
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
[("z1", "groupRect", {"width": 640, "height": 480, "border": "#0ff"}, None, None)],
|
||||
)
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
assert (await _sizes(engine))["z1"] == (640, 480)
|
||||
# The style the blob actually owns is untouched.
|
||||
async with engine.begin() as conn:
|
||||
blob = (await conn.exec_driver_sql("SELECT custom_colors FROM nodes")).scalar()
|
||||
assert json.loads(blob)["border"] == "#0ff"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_never_overwrites_a_size_already_in_the_columns(tmp_path, monkeypatch):
|
||||
# A zone resized since the upgrade: the column is the truth, and a stale
|
||||
# blob left over from before must not win.
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
[("z1", "groupRect", {"width": 111, "height": 222}, 800, None)],
|
||||
)
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
assert (await _sizes(engine))["z1"] == (800, 222)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_leaves_other_node_types_alone(tmp_path, monkeypatch):
|
||||
# Only zones ever stashed their size in the blob. A width key on any other
|
||||
# type is not geometry we should be moving.
|
||||
engine = await _engine(tmp_path, [("s1", "server", {"width": 999}, None, None)])
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
assert (await _sizes(engine))["s1"] == (None, None)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_skips_a_blob_with_no_usable_size(tmp_path, monkeypatch):
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
[
|
||||
("colors_only", "groupRect", {"border": "#0ff"}, None, None),
|
||||
("not_a_number", "groupRect", {"width": "wide", "height": True}, None, None),
|
||||
("no_blob", "groupRect", None, None, None),
|
||||
],
|
||||
)
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
sizes = await _sizes(engine)
|
||||
assert sizes["colors_only"] == (None, None)
|
||||
assert sizes["not_a_number"] == (None, None)
|
||||
assert sizes["no_blob"] == (None, None)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_survives_a_corrupt_blob_without_killing_boot(tmp_path, monkeypatch):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'zones.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DDL)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, custom_colors) VALUES ('bad', 'groupRect', 'b', '{oops')"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, custom_colors) "
|
||||
"VALUES ('good', 'groupRect', 'g', '{\"width\": 500, \"height\": 400}')"
|
||||
)
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
sizes = await _sizes(engine)
|
||||
assert sizes["bad"] == (None, None)
|
||||
# One unreadable row must not cost the others their size.
|
||||
assert sizes["good"] == (500, 400)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_is_idempotent(tmp_path, monkeypatch):
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
[("z1", "groupRect", {"width": 640, "height": 480}, None, None)],
|
||||
)
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
assert (await _sizes(engine))["z1"] == (640, 480)
|
||||
await engine.dispose()
|
||||
+17
-1
@@ -55,9 +55,25 @@ Click **Fetch Devices**. Homelable will:
|
||||
1. Connect to the broker
|
||||
2. Subscribe to the response topic
|
||||
3. Publish `{"type": "raw", "routes": false}` to the request topic
|
||||
4. Wait up to 60 seconds for the network map response (large meshes can take 30 s+)
|
||||
4. Wait up to `ZIGBEE_NETWORKMAP_TIMEOUT` seconds (default 300) for the network map response
|
||||
5. Parse and group devices by type
|
||||
|
||||
The fetch runs server-side and the browser polls for the result, so a mesh that
|
||||
takes minutes to answer cannot be cut short by a reverse proxy's read timeout.
|
||||
|
||||
### Large meshes and timeouts
|
||||
|
||||
A network of 200+ devices can take several minutes to build its map. Two knobs:
|
||||
|
||||
| Variable | Default | What it bounds |
|
||||
|---|---|---|
|
||||
| `ZIGBEE_NETWORKMAP_TIMEOUT` | `300` | Seconds to wait for the Z2M bridge to answer a networkmap request. Raise it if an import fails with *Timed out waiting for networkmap response*. |
|
||||
| `MQTT_RESPONSE_TIMEOUT` | `300` | The same bound for the Z-Wave MQTT round-trip. |
|
||||
|
||||
Both apply to manual imports and to auto-sync. If you reverse-proxy the API,
|
||||
these are the only timeouts that matter — the import request itself returns
|
||||
immediately.
|
||||
|
||||
### 5. Select and add to canvas
|
||||
|
||||
Devices are grouped by type (Coordinator / Router / End Device).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.5",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+34
-3
@@ -15,6 +15,7 @@ import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { toast } from 'sonner'
|
||||
import { isZoneSubnetCandidate } from '@/utils/subnet'
|
||||
import { CanvasContainer } from '@/components/canvas/CanvasContainer'
|
||||
import { Sidebar } from '@/components/panels/Sidebar'
|
||||
import { Toolbar } from '@/components/panels/Toolbar'
|
||||
@@ -64,7 +65,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, addToZone, 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, importZoneSubnet, floorMap, setFloorMap } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated, isInitialized } = useAuthStore()
|
||||
const authBootstrapStarted = useRef(false)
|
||||
@@ -526,6 +527,26 @@ export default function App() {
|
||||
toast.success(`Added "${data.label}"`)
|
||||
}, [addNode, nodes, snapshotHistory])
|
||||
|
||||
// Subnet import is a one-shot action on an existing zone, so on the Add modal
|
||||
// there is no zone to run it against yet: the CIDR is held here and applied
|
||||
// once, right after the zone is created. Cleared whenever that modal closes.
|
||||
const pendingZoneSubnet = useRef<string | null>(null)
|
||||
|
||||
const countSubnetMatches = useCallback(
|
||||
(cidr: string, zoneId?: string) =>
|
||||
nodes.filter((n) => isZoneSubnetCandidate(n, cidr, zoneId)).length,
|
||||
[nodes],
|
||||
)
|
||||
|
||||
const reportSubnetImport = useCallback((moved: number, cidr: string) => {
|
||||
if (moved === 0) toast.info(`No unparented device in ${cidr}`)
|
||||
else toast.success(`Moved ${moved} device${moved > 1 ? 's' : ''} from ${cidr} into the zone`)
|
||||
}, [])
|
||||
|
||||
const handleImportSubnetIntoZone = useCallback((zoneId: string, cidr: string) => {
|
||||
reportSubnetImport(importZoneSubnet(zoneId, cidr), cidr)
|
||||
}, [importZoneSubnet, reportSubnetImport])
|
||||
|
||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
snapshotHistory()
|
||||
const id = generateUUID()
|
||||
@@ -556,7 +577,12 @@ export default function App() {
|
||||
zIndex: data.z_order - 10,
|
||||
}
|
||||
addNode(newNode)
|
||||
}, [addNode, snapshotHistory])
|
||||
|
||||
const cidr = pendingZoneSubnet.current
|
||||
pendingZoneSubnet.current = null
|
||||
// addNode has already committed, so the zone is in the store by now.
|
||||
if (cidr) reportSubnetImport(importZoneSubnet(id, cidr), cidr)
|
||||
}, [addNode, snapshotHistory, importZoneSubnet, reportSubnetImport])
|
||||
|
||||
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
if (!editingGroupRectId) return
|
||||
@@ -1190,8 +1216,11 @@ export default function App() {
|
||||
|
||||
<GroupRectModal
|
||||
open={addGroupRectOpen}
|
||||
onClose={() => setAddGroupRectOpen(false)}
|
||||
onClose={() => { pendingZoneSubnet.current = null; setAddGroupRectOpen(false) }}
|
||||
onSubmit={handleAddGroupRect}
|
||||
onImportSubnet={(cidr) => { pendingZoneSubnet.current = cidr }}
|
||||
countSubnetMatches={(cidr) => countSubnetMatches(cidr)}
|
||||
importOnSubmit
|
||||
title="Add Zone"
|
||||
/>
|
||||
|
||||
@@ -1202,6 +1231,8 @@ export default function App() {
|
||||
onClose={() => setEditingGroupRectId(null)}
|
||||
onSubmit={handleUpdateGroupRect}
|
||||
onDelete={handleDeleteGroupRect}
|
||||
onImportSubnet={(cidr) => { if (editingGroupRectId) handleImportSubnetIntoZone(editingGroupRectId, cidr) }}
|
||||
countSubnetMatches={(cidr) => countSubnetMatches(cidr, editingGroupRectId ?? undefined)}
|
||||
initial={(() => {
|
||||
const n = editingGroupRectId ? nodes.find((nd) => nd.id === editingGroupRectId) : null
|
||||
if (!n) return undefined
|
||||
|
||||
@@ -242,6 +242,11 @@ describe('api/client', () => {
|
||||
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
||||
})
|
||||
|
||||
it('zigbeeApi.getImportJob polls the job by id', () => {
|
||||
mod.zigbeeApi.getImportJob('job-1')
|
||||
expect(api.get).toHaveBeenCalledWith('/zigbee/import/job-1')
|
||||
})
|
||||
|
||||
it('zwaveApi.testConnection/importNetwork/importToPending', () => {
|
||||
const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' }
|
||||
mod.zwaveApi.testConnection(cfg)
|
||||
|
||||
@@ -109,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.
|
||||
@@ -158,8 +172,17 @@ export const scanApi = {
|
||||
*/
|
||||
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}`),
|
||||
@@ -282,6 +305,8 @@ export const racksApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
export type ZigbeeImportJobStatus = 'running' | 'done' | 'error'
|
||||
|
||||
export interface ZigbeeConfigData {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
@@ -335,11 +360,20 @@ export const zigbeeApi = {
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{
|
||||
nodes: import('@/components/zigbee/types').ZigbeeNode[]
|
||||
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
||||
device_count: number
|
||||
}>('/zigbee/import', data),
|
||||
api.post<{ job_id: string; status: ZigbeeImportJobStatus }>('/zigbee/import', data),
|
||||
|
||||
// Poll a canvas import started by importNetwork. The fetch runs server-side
|
||||
// so a slow mesh cannot outlive a reverse proxy's read timeout.
|
||||
getImportJob: (jobId: string) =>
|
||||
api.get<{
|
||||
job_id: string
|
||||
status: ZigbeeImportJobStatus
|
||||
result: {
|
||||
nodes: import('@/components/zigbee/types').ZigbeeNode[]
|
||||
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
||||
device_count: number
|
||||
} | null
|
||||
}>(`/zigbee/import/${jobId}`),
|
||||
|
||||
importToPending: (data: {
|
||||
mqtt_host: string
|
||||
|
||||
@@ -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,12 @@ 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>
|
||||
{prop.value.trim() && (
|
||||
<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>
|
||||
|
||||
@@ -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,8 @@ 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>
|
||||
{prop.value.trim() && (
|
||||
<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'])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { TextPosition } from '@/types'
|
||||
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||
import { isValidCidr } from '@/utils/subnet'
|
||||
import styles from './GroupRectModal.module.css'
|
||||
|
||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
@@ -98,16 +99,51 @@ interface GroupRectModalProps {
|
||||
onDelete?: () => void
|
||||
initial?: Partial<GroupRectFormData>
|
||||
title?: string
|
||||
/** Run the subnet import for a valid CIDR. Omit to hide the whole section. */
|
||||
onImportSubnet?: (cidr: string) => void
|
||||
/** How many unparented devices a CIDR would pull in — drives the preview line. */
|
||||
countSubnetMatches?: (cidr: string) => number
|
||||
/**
|
||||
* Add mode: the zone does not exist yet, so there is nothing to import into
|
||||
* until it is created. The CIDR is handed over on submit instead of via an
|
||||
* Import button, which would otherwise look like it did nothing.
|
||||
*/
|
||||
importOnSubmit?: boolean
|
||||
}
|
||||
|
||||
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: GroupRectModalProps) {
|
||||
export function GroupRectModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
initial,
|
||||
title = 'Add Zone',
|
||||
onImportSubnet,
|
||||
countSubnetMatches,
|
||||
importOnSubmit = false,
|
||||
}: GroupRectModalProps) {
|
||||
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
|
||||
const [subnet, setSubnet] = useState('')
|
||||
|
||||
const cidrValid = isValidCidr(subnet)
|
||||
const showCidrError = subnet.trim() !== '' && !cidrValid
|
||||
const canImport = cidrValid && !!onImportSubnet
|
||||
const matchCount = cidrValid && countSubnetMatches ? countSubnetMatches(subnet) : null
|
||||
|
||||
const handleImport = () => {
|
||||
if (!canImport) return
|
||||
onImportSubnet!(subnet)
|
||||
setSubnet('')
|
||||
}
|
||||
|
||||
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Hand the CIDR over first: the caller queues it, then creates the zone and
|
||||
// runs the import against it.
|
||||
if (importOnSubmit && canImport) onImportSubnet!(subnet)
|
||||
onSubmit(form)
|
||||
onClose()
|
||||
}
|
||||
@@ -355,6 +391,57 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
</div>{/* ── end RIGHT column ── */}
|
||||
</div>{/* ── end 2-column grid ── */}
|
||||
|
||||
{/* ── Import devices by subnet ──
|
||||
Deliberately NOT part of the form: the CIDR is an argument to a
|
||||
one-shot action, never a property of the zone, so it is not
|
||||
submitted, not persisted, and cleared after each run. */}
|
||||
{onImportSubnet && (
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-[#30363d]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
Import devices by subnet
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={subnet}
|
||||
onChange={(e) => setSubnet(e.target.value)}
|
||||
placeholder="192.168.1.0/24"
|
||||
aria-label="Subnet"
|
||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 font-mono ${modalStyles['modal-radius']}`}
|
||||
style={showCidrError ? { borderColor: '#f85149' } : undefined}
|
||||
// Enter runs the import instead of submitting the whole form —
|
||||
// except in add mode, where submitting IS how the import runs.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !importOnSubmit) {
|
||||
e.preventDefault()
|
||||
handleImport()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!importOnSubmit && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canImport}
|
||||
className="cursor-pointer border-[#30363d] bg-[#21262d] shrink-0"
|
||||
onClick={handleImport}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
{showCidrError
|
||||
? <span className="text-[#f85149]">Not a valid IPv4 CIDR — try 192.168.1.0/24</span>
|
||||
: matchCount === null
|
||||
? 'Moves every unparented device in that range into this zone. Nothing is removed.'
|
||||
: matchCount === 0
|
||||
? 'No unparented device in that range.'
|
||||
: `${matchCount} device${matchCount > 1 ? 's' : ''} will move into this zone${importOnSubmit ? ' when you add it' : ''}.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete && (
|
||||
<Button
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* badges, then three columns — identity / operations / curation — so a device
|
||||
* reads in one screen instead of a scrolling column of key-value pairs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
HeartPulse,
|
||||
History,
|
||||
Layers,
|
||||
Loader2,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
Radar,
|
||||
StickyNote,
|
||||
Tags,
|
||||
X,
|
||||
@@ -40,6 +42,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { PropertyList } from '@/components/common/PropertyList'
|
||||
import { ServiceModal } from './ServiceModal'
|
||||
import { DeepScanModal } from './DeepScanModal'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
@@ -49,6 +52,7 @@ import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
import { isRackDevice, orderedSources, SOURCE_META } from '@/utils/deviceSources'
|
||||
import { DEVICE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
|
||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
import { countPorts } from '@/utils/portSpec'
|
||||
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
import { NODE_TYPE_LABELS, type CheckMethod, type InventoryEntry, type NodeProperty, type NodeType, type ServiceInfo } from '@/types'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
@@ -263,18 +267,89 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
const [services, setServices] = useState<ServiceInfo[]>(device?.services ?? [])
|
||||
const [svcModal, setSvcModal] = useState<{ index: number | null; form?: ServiceFormData } | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// The id of the deep rescan this modal started, while it is still running.
|
||||
const [rescanRunId, setRescanRunId] = useState<string | null>(null)
|
||||
const [rescanStarting, setRescanStarting] = useState(false)
|
||||
// The port-range dialog, opened by the Deep scan link.
|
||||
const [deepScanOpen, setDeepScanOpen] = useState(false)
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
// Kept in a ref so the poll effect below doesn't restart — and lose its
|
||||
// timer — every time the parent re-renders with a new callback identity.
|
||||
const onSavedRef = useRef(onSaved)
|
||||
onSavedRef.current = onSaved
|
||||
const editingRef = useRef(editing)
|
||||
editingRef.current = editing
|
||||
// Read by the reset effect, which keys on the id alone — the object itself
|
||||
// must not be a dependency.
|
||||
const deviceRef = useRef(device)
|
||||
deviceRef.current = device
|
||||
|
||||
// Reset the form whenever the modal is pointed at another device — the
|
||||
// Reset the form whenever the modal is pointed at *another* device — the
|
||||
// component stays mounted across card clicks in the inventory grid.
|
||||
//
|
||||
// Keyed on the id, never on the object: the parent hands down a fresh
|
||||
// `device` every time the row is refreshed from anywhere — a finishing deep
|
||||
// rescan, a status push, a save — and resetting on those would drop an edit
|
||||
// in progress along with the mode itself. A refreshed row is not a different
|
||||
// device, and only a different device is a reason to throw the form away.
|
||||
const deviceId = device?.id ?? null
|
||||
useEffect(() => {
|
||||
if (!device) return
|
||||
setForm(toForm(device))
|
||||
setProperties(device.properties ?? [])
|
||||
setServices(device.services ?? [])
|
||||
const d = deviceRef.current
|
||||
if (!d) return
|
||||
setForm(toForm(d))
|
||||
setProperties(d.properties ?? [])
|
||||
setServices(d.services ?? [])
|
||||
setEditing(false)
|
||||
setSvcModal(null)
|
||||
}, [device])
|
||||
setRescanRunId(null)
|
||||
setDeepScanOpen(false)
|
||||
}, [deviceId])
|
||||
|
||||
// Wait on a running deep rescan. A full 65535-port scan takes minutes, so the
|
||||
// run is polled rather than awaited — the user can close the modal, and the
|
||||
// scan keeps going and still shows under Scan History.
|
||||
useEffect(() => {
|
||||
if (!rescanRunId || !deviceId) return
|
||||
let stopped = false
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const { data: run } = await scanApi.run(rescanRunId)
|
||||
if (stopped || run.status === 'running') return
|
||||
setRescanRunId(null)
|
||||
if (run.status === 'error' || run.status === 'failed') {
|
||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||
return
|
||||
}
|
||||
if (run.status === 'cancelled') {
|
||||
toast.info('Scan stopped')
|
||||
return
|
||||
}
|
||||
const { data: rows } = await scanApi.pending()
|
||||
const fresh = (rows as InventoryEntry[]).find((d) => d.id === deviceId)
|
||||
if (!fresh || stopped) return
|
||||
// Never clobber an edit in progress — the user's unsaved services win.
|
||||
if (!editingRef.current) setServices(fresh.services ?? [])
|
||||
useCanvasStore.getState().applyDeviceFacts(fresh.id, deviceFactsToNodeData(fresh))
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
onSavedRef.current?.(fresh)
|
||||
const n = fresh.services?.length ?? 0
|
||||
const summary = `${n} service${n !== 1 ? 's' : ''}`
|
||||
// A done run can still carry an advisory: the sweep ran out of budget
|
||||
// before every port range. Saying "done" flat would read as complete.
|
||||
if (run.error) {
|
||||
toast.warning(`Scan partial — ${summary}. ${run.error}`)
|
||||
} else {
|
||||
toast.success(`Scan done — ${summary}`)
|
||||
}
|
||||
} catch {
|
||||
// Transient failure: keep polling, the run is still on the server.
|
||||
}
|
||||
}, 3000)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [rescanRunId, deviceId])
|
||||
|
||||
if (!device) return null
|
||||
|
||||
@@ -301,6 +376,31 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
setServices(device.services ?? [])
|
||||
}
|
||||
|
||||
const handleRescan = async (ports: string) => {
|
||||
if (!device.ip || rescanRunId || rescanStarting) return
|
||||
setRescanStarting(true)
|
||||
try {
|
||||
const res = await scanApi.rescanDevice(device.id, { ports })
|
||||
setRescanRunId(res.data.id)
|
||||
const n = countPorts(ports)
|
||||
toast.info(`Deep scan started — ${n.toLocaleString('en-US')} ports, this takes a few minutes`)
|
||||
} catch (err) {
|
||||
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
toast.error(detail ?? 'Could not start the scan')
|
||||
} finally {
|
||||
setRescanStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRescan = async () => {
|
||||
if (!rescanRunId) return
|
||||
try {
|
||||
await scanApi.stop(rescanRunId)
|
||||
} catch {
|
||||
toast.error('Could not stop the scan')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitService = (data: ServiceSubmitData) => {
|
||||
const svc: ServiceInfo = {
|
||||
...(data.port != null ? { port: data.port } : {}),
|
||||
@@ -523,7 +623,10 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
<PropertyList
|
||||
properties={properties}
|
||||
onChange={setProperties}
|
||||
visibleLabel="Show on node"
|
||||
// Whether a canvas draws a property is that node's own
|
||||
// answer; the flag here only decides what a node drawing
|
||||
// this device from now on starts out showing.
|
||||
visibleLabel="Show on new nodes"
|
||||
// Hardware is a property like any other; these are the keys
|
||||
// the Proxmox import and the YAML import already mint.
|
||||
suggestions={['CPU Model', 'CPU Cores', 'RAM', 'Disk']}
|
||||
@@ -603,7 +706,36 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
|
||||
{/* Zigbee devices have no IP services — the section would always be empty. */}
|
||||
{!isZigbee && (
|
||||
<Section title={`Services found (${device.services.length})`} icon={Network}>
|
||||
<Section
|
||||
title={`Services found (${device.services.length})`}
|
||||
icon={Network}
|
||||
action={
|
||||
// Deep rescan: the fix for a device added before the
|
||||
// scanner knew its services, or one listening on a port
|
||||
// no curated list covers (issue #350). Needs an IP.
|
||||
device.ip ? (
|
||||
rescanRunId ? (
|
||||
<button
|
||||
onClick={handleStopRescan}
|
||||
data-testid="device-rescan-stop"
|
||||
className="flex items-center gap-1 text-[10px] text-[#f85149] hover:text-[#f85149]/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Loader2 size={10} className="animate-spin" /> Scanning — stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeepScanOpen(true)}
|
||||
disabled={rescanStarting}
|
||||
data-testid="device-rescan"
|
||||
title="Pick a port range and refresh the services"
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Radar size={10} /> Deep scan
|
||||
</button>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{device.services.length === 0 ? (
|
||||
<Empty>No services detected</Empty>
|
||||
) : (
|
||||
@@ -753,6 +885,15 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deepScanOpen && (
|
||||
<DeepScanModal
|
||||
open
|
||||
target={device.ip}
|
||||
onClose={() => setDeepScanOpen(false)}
|
||||
onStart={handleRescan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{svcModal && (
|
||||
<ServiceModal
|
||||
key={`svc-${svcModal.index ?? 'new'}`}
|
||||
|
||||
@@ -345,3 +345,110 @@ describe('GroupRectModal font label rendering', () => {
|
||||
expect(trigger.textContent).toContain('comic-sans-9000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupRectModal — subnet import', () => {
|
||||
const setup = (props: Partial<React.ComponentProps<typeof GroupRectModal>> = {}) => {
|
||||
const onImportSubnet = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onImportSubnet={onImportSubnet}
|
||||
countSubnetMatches={() => 3}
|
||||
title="Edit Zone"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return { onImportSubnet, onSubmit, onClose }
|
||||
}
|
||||
|
||||
it('hides the whole section when no import handler is given', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('Import devices by subnet')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the field and disables Import until a CIDR is valid', () => {
|
||||
setup()
|
||||
const button = screen.getByRole('button', { name: 'Import' }) as HTMLButtonElement
|
||||
expect(button.disabled).toBe(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Subnet'), { target: { value: '192.168.1.0/24' } })
|
||||
expect(button.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('explains an invalid CIDR instead of silently doing nothing', () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByLabelText('Subnet'), { target: { value: '192.168.1.0/99' } })
|
||||
expect(screen.getByText(/Not a valid IPv4 CIDR/)).toBeInTheDocument()
|
||||
expect((screen.getByRole('button', { name: 'Import' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('previews how many devices would move', () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByLabelText('Subnet'), { target: { value: '192.168.1.0/24' } })
|
||||
expect(screen.getByText('3 devices will move into this zone.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('imports without submitting or closing the form, and clears the field', () => {
|
||||
const { onImportSubnet, onSubmit, onClose } = setup()
|
||||
const field = screen.getByLabelText('Subnet') as HTMLInputElement
|
||||
fireEvent.change(field, { target: { value: '192.168.1.0/24' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
|
||||
|
||||
expect(onImportSubnet).toHaveBeenCalledWith('192.168.1.0/24')
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(field.value).toBe('')
|
||||
})
|
||||
|
||||
it('runs the import on Enter rather than submitting the zone', () => {
|
||||
const { onImportSubnet, onSubmit } = setup()
|
||||
const field = screen.getByLabelText('Subnet')
|
||||
fireEvent.change(field, { target: { value: '10.0.0.0/8' } })
|
||||
fireEvent.keyDown(field, { key: 'Enter' })
|
||||
|
||||
expect(onImportSubnet).toHaveBeenCalledWith('10.0.0.0/8')
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never submits the CIDR as zone data — it is an argument, not a property', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByLabelText('Subnet'), { target: { value: '192.168.1.0/24' } })
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
expect(Object.keys(onSubmit.mock.calls[0][0])).not.toContain('subnet')
|
||||
})
|
||||
|
||||
describe('add mode (importOnSubmit)', () => {
|
||||
it('drops the Import button — there is no zone to import into yet', () => {
|
||||
setup({ importOnSubmit: true, title: 'Add Zone' })
|
||||
expect(screen.getByText('Import devices by subnet')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
|
||||
})
|
||||
|
||||
it('says the move happens on creation', () => {
|
||||
setup({ importOnSubmit: true, title: 'Add Zone' })
|
||||
fireEvent.change(screen.getByLabelText('Subnet'), { target: { value: '192.168.1.0/24' } })
|
||||
expect(screen.getByText('3 devices will move into this zone when you add it.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hands the CIDR over before submitting, so the caller can apply it to the new zone', () => {
|
||||
const { onImportSubnet, onSubmit } = setup({ importOnSubmit: true, title: 'Add Zone' })
|
||||
fireEvent.change(screen.getByLabelText('Subnet'), { target: { value: '192.168.1.0/24' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
|
||||
expect(onImportSubnet).toHaveBeenCalledWith('192.168.1.0/24')
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
expect(onImportSubnet.mock.invocationCallOrder[0]).toBeLessThan(onSubmit.mock.invocationCallOrder[0])
|
||||
})
|
||||
|
||||
it('submits normally when the field is left empty', () => {
|
||||
const { onImportSubnet, onSubmit } = setup({ importOnSubmit: true, title: 'Add Zone' })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onImportSubnet).not.toHaveBeenCalled()
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -134,6 +134,16 @@ export function DetailPanel({ onEdit, onOpenInventory }: 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
|
||||
@@ -260,6 +270,7 @@ export function DetailPanel({ onEdit, onOpenInventory }: DetailPanelProps) {
|
||||
setDragSvcIndex(null)
|
||||
setDragOverSvcIndex(null)
|
||||
}}
|
||||
onToggleVisible={() => handleToggleService(i)}
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
@@ -594,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
|
||||
@@ -605,6 +616,7 @@ function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, on
|
||||
onDragEnter: () => void
|
||||
onDragEnd: () => void
|
||||
onDrop: () => void
|
||||
onToggleVisible: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
@@ -615,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
|
||||
@@ -693,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"
|
||||
|
||||
@@ -546,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()} />)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Network, Router, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { zigbeeApi } from '@/api/client'
|
||||
import { pollImportJob, PollAbortedError } from '@/utils/importJobPoll'
|
||||
import { toast } from 'sonner'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from './types'
|
||||
|
||||
@@ -68,6 +69,10 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onInventoryImp
|
||||
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||
// Aborts the canvas-import poll loop when the modal closes or unmounts.
|
||||
const pollAbort = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => () => pollAbort.current?.abort(), [])
|
||||
|
||||
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||
setForm((f) => ({
|
||||
@@ -143,17 +148,27 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onInventoryImp
|
||||
onInventoryImported?.(null)
|
||||
handleClose()
|
||||
} else {
|
||||
const res = await zigbeeApi.importNetwork(buildPayload())
|
||||
setDevices(res.data.nodes)
|
||||
setEdges(res.data.edges)
|
||||
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||
if (res.data.device_count === 0) {
|
||||
// The backend fetches the network map in the background and we poll for
|
||||
// it — a large mesh takes minutes, which no reverse proxy will hold open.
|
||||
pollAbort.current?.abort()
|
||||
const controller = new AbortController()
|
||||
pollAbort.current = controller
|
||||
const start = await zigbeeApi.importNetwork(buildPayload())
|
||||
const result = await pollImportJob(
|
||||
async () => (await zigbeeApi.getImportJob(start.data.job_id)).data,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
setDevices(result.nodes)
|
||||
setEdges(result.edges)
|
||||
setChecked(new Set(result.nodes.map((n) => n.id)))
|
||||
if (result.device_count === 0) {
|
||||
toast.info('No Zigbee devices found in the network map')
|
||||
} else {
|
||||
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||
toast.success(`Found ${result.device_count} device${result.device_count !== 1 ? 's' : ''}`)
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof PollAbortedError) return
|
||||
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -181,6 +196,8 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onInventoryImp
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
pollAbort.current?.abort()
|
||||
pollAbort.current = null
|
||||
setDevices([])
|
||||
setEdges([])
|
||||
setChecked(new Set())
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { ZigbeeImportModal } from '../ZigbeeImportModal'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
zigbeeApi: {
|
||||
testConnection: vi.fn(),
|
||||
importNetwork: vi.fn(),
|
||||
getImportJob: vi.fn(),
|
||||
importToPending: vi.fn(),
|
||||
},
|
||||
}))
|
||||
@@ -51,6 +52,7 @@ describe('ZigbeeImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockReset()
|
||||
vi.mocked(zigbeeApi.importNetwork).mockReset()
|
||||
vi.mocked(zigbeeApi.getImportJob).mockReset()
|
||||
vi.mocked(zigbeeApi.importToPending).mockReset()
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
@@ -115,16 +117,33 @@ describe('ZigbeeImportModal', () => {
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
}
|
||||
|
||||
it('fetches devices and renders them grouped by type', async () => {
|
||||
/** A canvas import now answers with a job id; the payload arrives from a poll. */
|
||||
const mockCanvasImport = (result: {
|
||||
nodes: typeof sampleNodes
|
||||
edges: { source: string; target: string }[]
|
||||
device_count: number
|
||||
}) => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
data: { job_id: 'job-1', status: 'running' },
|
||||
} as never)
|
||||
vi.mocked(zigbeeApi.getImportJob).mockResolvedValue({
|
||||
data: { job_id: 'job-1', status: 'done', result },
|
||||
} as never)
|
||||
}
|
||||
|
||||
const startCanvasFetch = () => {
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
}
|
||||
|
||||
it('fetches devices and renders them grouped by type', async () => {
|
||||
mockCanvasImport({ nodes: sampleNodes, edges: [], device_count: 2 })
|
||||
|
||||
startCanvasFetch()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Coordinator')).toBeDefined()
|
||||
@@ -134,15 +153,9 @@ describe('ZigbeeImportModal', () => {
|
||||
})
|
||||
|
||||
it('shows info toast when no devices found', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: [], edges: [], device_count: 0 },
|
||||
} as never)
|
||||
mockCanvasImport({ nodes: [], edges: [], device_count: 0 })
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
startCanvasFetch()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.info).toHaveBeenCalledWith('No Zigbee devices found in the network map')
|
||||
@@ -150,15 +163,13 @@ describe('ZigbeeImportModal', () => {
|
||||
})
|
||||
|
||||
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [{ source: '0x0000', target: '0x0001' }], device_count: 2 },
|
||||
} as never)
|
||||
mockCanvasImport({
|
||||
nodes: sampleNodes,
|
||||
edges: [{ source: '0x0000', target: '0x0001' }],
|
||||
device_count: 2,
|
||||
})
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
startCanvasFetch()
|
||||
|
||||
await waitFor(() => screen.getByText('Coordinator'))
|
||||
|
||||
@@ -207,17 +218,81 @@ describe('ZigbeeImportModal', () => {
|
||||
})
|
||||
|
||||
it('switching to canvas mode calls importNetwork and not importToPending', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
mockCanvasImport({ nodes: sampleNodes, edges: [], device_count: 2 })
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
startCanvasFetch()
|
||||
|
||||
await waitFor(() => expect(zigbeeApi.importNetwork).toHaveBeenCalled())
|
||||
expect(zigbeeApi.importToPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps polling the job until the map is ready (issue #380)', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { job_id: 'job-1', status: 'running' },
|
||||
} as never)
|
||||
vi.mocked(zigbeeApi.getImportJob)
|
||||
.mockResolvedValueOnce({
|
||||
data: { job_id: 'job-1', status: 'running', result: null },
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
job_id: 'job-1',
|
||||
status: 'done',
|
||||
result: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
},
|
||||
} as never)
|
||||
|
||||
startCanvasFetch()
|
||||
|
||||
await waitFor(() => expect(zigbeeApi.getImportJob).toHaveBeenCalledTimes(1))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
})
|
||||
await waitFor(() => expect(screen.getByText('router_1')).toBeDefined())
|
||||
expect(zigbeeApi.getImportJob).toHaveBeenCalledWith('job-1')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces the backend detail when the job fails', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { job_id: 'job-1', status: 'running' },
|
||||
} as never)
|
||||
vi.mocked(zigbeeApi.getImportJob).mockRejectedValue({
|
||||
response: { status: 504, data: { detail: 'Timed out waiting for networkmap response' } },
|
||||
})
|
||||
|
||||
startCanvasFetch()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Timed out waiting for networkmap response')
|
||||
})
|
||||
})
|
||||
|
||||
it('stops polling and stays quiet when the modal is closed mid-import', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { job_id: 'job-1', status: 'running' },
|
||||
} as never)
|
||||
vi.mocked(zigbeeApi.getImportJob).mockResolvedValue({
|
||||
data: { job_id: 'job-1', status: 'running', result: null },
|
||||
} as never)
|
||||
|
||||
const { unmount } = render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => expect(zigbeeApi.getImportJob).toHaveBeenCalled())
|
||||
unmount()
|
||||
|
||||
const callsAtUnmount = vi.mocked(zigbeeApi.getImportJob).mock.calls.length
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(vi.mocked(zigbeeApi.getImportJob).mock.calls.length).toBe(callsAtUnmount)
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,3 +96,49 @@ describe('canvasStore — applyDeviceFacts', () => {
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { makeNode } from '@/test/factories'
|
||||
import { serializeNode, deserializeApiNode, type ApiNode } from '@/utils/canvasSerializer'
|
||||
|
||||
function resetStore() {
|
||||
useCanvasStore.setState({
|
||||
@@ -133,3 +134,187 @@ describe('canvasStore — zone (groupRect) parenting', () => {
|
||||
expect(detached.position).toEqual({ x: 160, y: 220 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvasStore — importZoneSubnet', () => {
|
||||
beforeEach(resetStore)
|
||||
|
||||
const device = (id: string, ip: string | undefined, over: Partial<ReturnType<typeof makeNode>> = {}) => ({
|
||||
...makeNode(id, { ip }),
|
||||
position: { x: 900, y: 900 },
|
||||
...over,
|
||||
})
|
||||
|
||||
it('moves every free device in range into the zone and reports the count', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), device('n1', '192.168.1.10'), device('n2', '192.168.1.11')],
|
||||
})
|
||||
|
||||
const moved = useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
|
||||
expect(moved).toBe(2)
|
||||
const nodes = useCanvasStore.getState().nodes
|
||||
expect(nodes.find((n) => n.id === 'n1')!.parentId).toBe('z1')
|
||||
expect(nodes.find((n) => n.id === 'n2')!.parentId).toBe('z1')
|
||||
})
|
||||
|
||||
it('leaves out-of-range and address-less devices on the canvas', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), device('in', '192.168.1.10'), device('out', '10.0.0.1'), device('bare', undefined)],
|
||||
})
|
||||
|
||||
expect(useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')).toBe(1)
|
||||
const nodes = useCanvasStore.getState().nodes
|
||||
expect(nodes.find((n) => n.id === 'out')!.parentId).toBeUndefined()
|
||||
expect(nodes.find((n) => n.id === 'bare')!.parentId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never steals a node that already has a parent', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
zone('z1'),
|
||||
zone('z2', 800, 100),
|
||||
{ ...device('n1', '192.168.1.10'), parentId: 'z2' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')).toBe(0)
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!.parentId).toBe('z2')
|
||||
})
|
||||
|
||||
it('never swallows another zone, even one carrying an IP', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), { ...zone('z2', 800, 100), data: { ...zone('z2').data, ip: '192.168.1.9' } }],
|
||||
})
|
||||
|
||||
expect(useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')).toBe(0)
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'z2')!.parentId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op for an invalid CIDR, a missing zone and a non-zone target', () => {
|
||||
useCanvasStore.setState({ nodes: [zone('z1'), device('n1', '192.168.1.10'), device('plain', '192.168.1.11')] })
|
||||
const before = useCanvasStore.getState().nodes
|
||||
|
||||
expect(useCanvasStore.getState().importZoneSubnet('z1', 'nonsense')).toBe(0)
|
||||
expect(useCanvasStore.getState().importZoneSubnet('nope', '192.168.1.0/24')).toBe(0)
|
||||
expect(useCanvasStore.getState().importZoneSubnet('plain', '192.168.1.0/24')).toBe(0)
|
||||
expect(useCanvasStore.getState().nodes).toBe(before)
|
||||
})
|
||||
|
||||
it('lays arrivals out on a non-overlapping grid inside the zone', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), device('n1', '192.168.1.10'), device('n2', '192.168.1.11')],
|
||||
})
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
|
||||
const nodes = useCanvasStore.getState().nodes
|
||||
const a = nodes.find((n) => n.id === 'n1')!.position
|
||||
const b = nodes.find((n) => n.id === 'n2')!.position
|
||||
expect(a).not.toEqual(b)
|
||||
// Zone-relative and clear of the label band at the top.
|
||||
for (const p of [a, b]) {
|
||||
expect(p.x).toBeGreaterThanOrEqual(0)
|
||||
expect(p.y).toBeGreaterThanOrEqual(40)
|
||||
}
|
||||
})
|
||||
|
||||
it('packs around the boxes already inside the zone', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
zone('z1'),
|
||||
{ ...device('sitting', '10.0.0.1'), parentId: 'z1', position: { x: 16, y: 40 }, width: 160, height: 90 },
|
||||
device('n1', '192.168.1.10'),
|
||||
],
|
||||
})
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
|
||||
const placed = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!.position
|
||||
expect(placed).not.toEqual({ x: 16, y: 40 })
|
||||
})
|
||||
|
||||
it('grows the zone when the arrivals overflow its height', () => {
|
||||
const many = Array.from({ length: 12 }, (_, i) => device(`n${i}`, `192.168.1.${i + 10}`))
|
||||
useCanvasStore.setState({ nodes: [zone('z1'), ...many] })
|
||||
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
|
||||
const z = useCanvasStore.getState().nodes.find((n) => n.id === 'z1')!
|
||||
expect(z.height!).toBeGreaterThan(300)
|
||||
|
||||
// The grown height has to survive a save/load round-trip.
|
||||
const wire = serializeNode(z) as unknown as ApiNode
|
||||
expect(wire.height).toBe(z.height)
|
||||
const reloaded = deserializeApiNode(wire, new Map())
|
||||
expect(reloaded.height).toBe(z.height)
|
||||
})
|
||||
|
||||
it('keeps the parent ahead of its new children, as React Flow requires', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [device('n1', '192.168.1.10'), zone('z1'), device('n2', '192.168.1.11')],
|
||||
})
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
|
||||
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
|
||||
expect(ids.indexOf('z1')).toBeLessThan(ids.indexOf('n1'))
|
||||
expect(ids.indexOf('z1')).toBeLessThan(ids.indexOf('n2'))
|
||||
})
|
||||
|
||||
it('keeps an arrival that is itself a parent ahead of the children it leaves behind', () => {
|
||||
// A Proxmox host matches the subnet; its VM does not move, because it
|
||||
// already has a parent. The host must still be listed before the VM.
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
device('proxmox', '192.168.1.10'),
|
||||
{ ...device('vm1', '10.0.0.1'), parentId: 'proxmox' },
|
||||
zone('z1'),
|
||||
],
|
||||
})
|
||||
|
||||
expect(useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')).toBe(1)
|
||||
|
||||
const nodes = useCanvasStore.getState().nodes
|
||||
const ids = nodes.map((n) => n.id)
|
||||
expect(nodes.find((n) => n.id === 'proxmox')!.parentId).toBe('z1')
|
||||
expect(nodes.find((n) => n.id === 'vm1')!.parentId).toBe('proxmox')
|
||||
expect(ids.indexOf('z1')).toBeLessThan(ids.indexOf('proxmox'))
|
||||
expect(ids.indexOf('proxmox')).toBeLessThan(ids.indexOf('vm1'))
|
||||
})
|
||||
|
||||
it('leaves an already-valid order untouched', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), device('a', '10.0.0.1'), device('b', '10.0.0.2'), device('hit', '192.168.1.10')],
|
||||
})
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
|
||||
// Only the matched node moves in the array; the untouched ones keep their
|
||||
// relative order.
|
||||
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
|
||||
expect(ids.indexOf('a')).toBeLessThan(ids.indexOf('b'))
|
||||
expect(ids[0]).toBe('z1')
|
||||
})
|
||||
|
||||
it('is additive: a second subnet keeps the first import inside', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), device('n1', '192.168.1.10'), device('n2', '10.0.0.5')],
|
||||
})
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '10.0.0.0/8')
|
||||
|
||||
const nodes = useCanvasStore.getState().nodes
|
||||
expect(nodes.find((n) => n.id === 'n1')!.parentId).toBe('z1')
|
||||
expect(nodes.find((n) => n.id === 'n2')!.parentId).toBe('z1')
|
||||
})
|
||||
|
||||
it('marks the canvas unsaved and undoes the whole import in one step', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [zone('z1'), device('n1', '192.168.1.10'), device('n2', '192.168.1.11')],
|
||||
})
|
||||
useCanvasStore.getState().importZoneSubnet('z1', '192.168.1.0/24')
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
|
||||
useCanvasStore.getState().undo()
|
||||
|
||||
const nodes = useCanvasStore.getState().nodes
|
||||
expect(nodes.find((n) => n.id === 'n1')!.parentId).toBeUndefined()
|
||||
expect(nodes.find((n) => n.id === 'n2')!.parentId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,8 +14,15 @@ import { generateUUID } from '@/utils/uuid'
|
||||
import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handleId, SIDES } from '@/utils/handleUtils'
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||
import { isValidCidr, isZoneSubnetCandidate } from '@/utils/subnet'
|
||||
import { CONTAINER_MODE_TYPES } from '@/utils/virtualEdgeParent'
|
||||
import { changedFactFields, factsBaselineOf, factsBaselines, type FactsBaseline } from '@/utils/deviceFacts'
|
||||
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>[] }
|
||||
@@ -23,6 +30,44 @@ type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
||||
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
||||
|
||||
/**
|
||||
* Reorder so every node follows its parent, which is what React Flow needs to
|
||||
* resolve nesting — a child listed first renders detached and logs a
|
||||
* parent-not-found error. Order is otherwise preserved: a list that is already
|
||||
* valid comes back untouched. A parent cycle terminates instead of recursing.
|
||||
*/
|
||||
function orderParentsFirst(nodes: Node<NodeData>[]): Node<NodeData>[] {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const emitted = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const out: Node<NodeData>[] = []
|
||||
|
||||
const visit = (n: Node<NodeData>) => {
|
||||
if (emitted.has(n.id) || visiting.has(n.id)) return
|
||||
visiting.add(n.id)
|
||||
const parent = n.parentId ? byId.get(n.parentId) : undefined
|
||||
if (parent) visit(parent)
|
||||
visiting.delete(n.id)
|
||||
emitted.add(n.id)
|
||||
out.push(n)
|
||||
}
|
||||
|
||||
for (const n of nodes) visit(n)
|
||||
return out
|
||||
}
|
||||
|
||||
// Zone subnet import: the grid the arrivals are packed on, in zone-relative
|
||||
// pixels. A cell is one node box plus the gap that follows it.
|
||||
const DEFAULT_ZONE_WIDTH = 360
|
||||
const DEFAULT_ZONE_HEIGHT = 240
|
||||
const ZONE_CELL_W = 180
|
||||
const ZONE_CELL_H = 110
|
||||
const ZONE_CELL_GAP = 20
|
||||
const ZONE_PAD_X = 32
|
||||
// Leaves the label band at the top of the zone clear.
|
||||
const ZONE_PAD_TOP = 40
|
||||
const ZONE_PAD_BOTTOM = 16
|
||||
|
||||
/**
|
||||
* Whether a node change represents a real user edit that should dirty the canvas.
|
||||
* Excludes:
|
||||
@@ -192,6 +237,9 @@ interface CanvasState {
|
||||
addToGroup: (groupId: string, childId: string) => void
|
||||
addToContainer: (containerId: string, childId: string) => void
|
||||
addToZone: (zoneId: string, childId: string) => void
|
||||
/** Move every free node whose IP falls in `cidr` into the zone. Returns the
|
||||
* count moved; 0 for an invalid CIDR or no match. */
|
||||
importZoneSubnet: (zoneId: string, cidr: string) => number
|
||||
removeFromGroup: (groupId: string, childId: string) => void
|
||||
markSaved: () => void
|
||||
markUnsaved: () => void
|
||||
@@ -212,7 +260,7 @@ interface CanvasState {
|
||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
export const useCanvasStore = create<CanvasState>((rawSet, get) => {
|
||||
// Wrap set so any update that flips hasUnsavedChanges to true also bumps
|
||||
// editSeq. This centralises the "an edit happened" signal instead of touching
|
||||
// every one of the ~two dozen mutating actions. Actions that update state
|
||||
@@ -963,6 +1011,99 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
}
|
||||
}),
|
||||
|
||||
// Pull every free node whose IP falls inside `cidr` into a zone, laying them
|
||||
// out on a grid in the zone's free space. Deliberately additive and one-shot:
|
||||
// the CIDR is never stored, nothing is ever ejected, and running it twice with
|
||||
// two subnets leaves both sets inside. Undo reverses the whole import at once.
|
||||
importZoneSubnet: (zoneId, cidr) => {
|
||||
const state = get()
|
||||
const zone = state.nodes.find((n) => n.id === zoneId)
|
||||
if (!zone || zone.data.type !== 'groupRect') return 0
|
||||
if (!isValidCidr(cidr)) return 0
|
||||
|
||||
const matches = state.nodes.filter((n) => isZoneSubnetCandidate(n, cidr, zoneId))
|
||||
if (matches.length === 0) return 0
|
||||
|
||||
const moved = new Set(matches.map((n) => n.id))
|
||||
const zoneWidth = zone.width ?? DEFAULT_ZONE_WIDTH
|
||||
const zoneHeight = zone.height ?? DEFAULT_ZONE_HEIGHT
|
||||
|
||||
// Boxes already inside the zone, in zone-relative coordinates. New arrivals
|
||||
// are packed around them rather than on top of them.
|
||||
const occupied = state.nodes
|
||||
.filter((n) => n.parentId === zoneId && !moved.has(n.id))
|
||||
.map((n) => ({
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
w: n.width ?? ZONE_CELL_W - ZONE_CELL_GAP,
|
||||
h: n.height ?? ZONE_CELL_H - ZONE_CELL_GAP,
|
||||
}))
|
||||
|
||||
const columns = Math.max(1, Math.floor((zoneWidth - ZONE_PAD_X) / ZONE_CELL_W))
|
||||
const overlaps = (x: number, y: number) =>
|
||||
occupied.some(
|
||||
(b) =>
|
||||
x < b.x + b.w &&
|
||||
x + ZONE_CELL_W - ZONE_CELL_GAP > b.x &&
|
||||
y < b.y + b.h &&
|
||||
y + ZONE_CELL_H - ZONE_CELL_GAP > b.y,
|
||||
)
|
||||
|
||||
// Row-major scan for the first free cell per arrival. Rows keep going past
|
||||
// the current height; the zone grows at the end to cover the last one used.
|
||||
const placements = new Map<string, { x: number; y: number }>()
|
||||
let cursor = 0
|
||||
let maxBottom = 0
|
||||
for (const node of matches) {
|
||||
let x = 0
|
||||
let y = 0
|
||||
for (;;) {
|
||||
x = ZONE_PAD_X / 2 + (cursor % columns) * ZONE_CELL_W
|
||||
y = ZONE_PAD_TOP + Math.floor(cursor / columns) * ZONE_CELL_H
|
||||
cursor += 1
|
||||
if (!overlaps(x, y)) break
|
||||
}
|
||||
placements.set(node.id, { x, y })
|
||||
occupied.push({ x, y, w: ZONE_CELL_W - ZONE_CELL_GAP, h: ZONE_CELL_H - ZONE_CELL_GAP })
|
||||
maxBottom = Math.max(maxBottom, y + ZONE_CELL_H)
|
||||
}
|
||||
|
||||
const grownHeight = Math.max(zoneHeight, maxBottom + ZONE_PAD_BOTTOM)
|
||||
|
||||
set((s) => {
|
||||
const updated = s.nodes.map((n) => {
|
||||
// `height` is the only field to write: the serializer persists it to
|
||||
// the real `nodes.height` column, and keeps it out of the style blob.
|
||||
if (n.id === zoneId) {
|
||||
return grownHeight === zoneHeight ? n : { ...n, height: grownHeight }
|
||||
}
|
||||
const at = placements.get(n.id)
|
||||
if (!at) return n
|
||||
return {
|
||||
...n,
|
||||
parentId: zoneId,
|
||||
position: at,
|
||||
selected: false,
|
||||
data: { ...n.data, parent_id: zoneId },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// React Flow requires a parent to precede its children in the array.
|
||||
// Reordering the whole list covers both directions at once: the zone
|
||||
// ahead of its new children, and an arrival that is itself a parent
|
||||
// (a Proxmox host with nested VMs, whose children stay put because
|
||||
// they already have a parent) ahead of the children it left behind.
|
||||
nodes: orderParentsFirst(updated),
|
||||
hasUnsavedChanges: true,
|
||||
past: [...s.past.slice(-49), { nodes: s.nodes, edges: s.edges }],
|
||||
future: [],
|
||||
}
|
||||
})
|
||||
|
||||
return matches.length
|
||||
},
|
||||
|
||||
// Release a single child from a group back to the canvas. Group stays.
|
||||
removeFromGroup: (groupId, childId) =>
|
||||
set((state) => {
|
||||
@@ -1012,6 +1153,15 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
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)
|
||||
|
||||
@@ -97,6 +97,10 @@ export interface ServiceInfo {
|
||||
* 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'
|
||||
|
||||
@@ -165,7 +165,7 @@ describe('serializeNode — regular node', () => {
|
||||
// ── serializeNode — groupRect ─────────────────────────────────────────────────
|
||||
|
||||
describe('serializeNode — groupRect', () => {
|
||||
it('stores dimensions inside custom_colors', () => {
|
||||
it('stores dimensions in the width/height columns, like every other node type', () => {
|
||||
const node = makeRfNode({
|
||||
type: 'groupRect',
|
||||
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
|
||||
@@ -173,8 +173,25 @@ describe('serializeNode — groupRect', () => {
|
||||
height: 250,
|
||||
})
|
||||
const result = serializeNode(node)
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
|
||||
expect(result.width).toBe(400)
|
||||
expect(result.height).toBe(250)
|
||||
})
|
||||
|
||||
it('keeps the size out of the style blob, so the two cannot drift apart', () => {
|
||||
const node = makeRfNode({
|
||||
type: 'groupRect',
|
||||
// A zone loaded before the move still carries the legacy keys in memory.
|
||||
data: {
|
||||
label: 'Z', type: 'groupRect', status: 'unknown', services: [],
|
||||
custom_colors: { border: '#aaa', width: 111, height: 222 },
|
||||
},
|
||||
width: 400,
|
||||
height: 250,
|
||||
})
|
||||
const cc = serializeNode(node).custom_colors as Record<string, unknown>
|
||||
expect(cc.width).toBeUndefined()
|
||||
expect(cc.height).toBeUndefined()
|
||||
expect(cc.border).toBe('#aaa')
|
||||
})
|
||||
|
||||
it('prefers explicit width/height over measured, falling back to measured per-axis', () => {
|
||||
@@ -184,15 +201,15 @@ describe('serializeNode — groupRect', () => {
|
||||
}
|
||||
const result = serializeNode(node)
|
||||
// Explicit width wins; height has no explicit value so it uses measured.
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(260)
|
||||
expect(result.width).toBe(400)
|
||||
expect(result.height).toBe(260)
|
||||
})
|
||||
|
||||
it('falls back to defaults when no dimensions available', () => {
|
||||
const node = makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] } })
|
||||
const result = serializeNode(node)
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(360)
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(240)
|
||||
expect(result.width).toBe(360)
|
||||
expect(result.height).toBe(240)
|
||||
})
|
||||
|
||||
it('preserves existing custom_colors fields alongside dimensions', () => {
|
||||
@@ -205,8 +222,8 @@ describe('serializeNode — groupRect', () => {
|
||||
const cc = result.custom_colors as Record<string, unknown>
|
||||
expect(cc.border).toBe('#aaa')
|
||||
expect(cc.z_order).toBe(2)
|
||||
expect(cc.width).toBe(300)
|
||||
expect(cc.height).toBe(200)
|
||||
expect(result.width).toBe(300)
|
||||
expect(result.height).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -420,9 +437,9 @@ describe('deserializeApiNode — regular node', () => {
|
||||
describe('deserializeApiNode — groupRect', () => {
|
||||
const emptyMap = new Map<string, boolean>()
|
||||
|
||||
it('restores width/height from custom_colors', () => {
|
||||
it('restores width/height from the columns', () => {
|
||||
const result = deserializeApiNode(
|
||||
makeApiNode({ type: 'groupRect', custom_colors: { width: 400, height: 250, z_order: 2 } }),
|
||||
makeApiNode({ type: 'groupRect', width: 400, height: 250, custom_colors: { z_order: 2 } }),
|
||||
emptyMap,
|
||||
)
|
||||
expect(result.width).toBe(400)
|
||||
@@ -430,7 +447,27 @@ describe('deserializeApiNode — groupRect', () => {
|
||||
expect(result.zIndex).toBe(-8)
|
||||
})
|
||||
|
||||
it('defaults to 360x240 when custom_colors has no dimensions', () => {
|
||||
it('still reads a legacy canvas that kept its size in custom_colors', () => {
|
||||
// Saved before the size moved to the columns, and not yet migrated: no
|
||||
// fallback here means the zone comes back at the default 360x240.
|
||||
const result = deserializeApiNode(
|
||||
makeApiNode({ type: 'groupRect', custom_colors: { width: 400, height: 250, z_order: 2 } }),
|
||||
emptyMap,
|
||||
)
|
||||
expect(result.width).toBe(400)
|
||||
expect(result.height).toBe(250)
|
||||
})
|
||||
|
||||
it('lets the columns win over a stale legacy blob', () => {
|
||||
const result = deserializeApiNode(
|
||||
makeApiNode({ type: 'groupRect', width: 500, height: 300, custom_colors: { width: 111, height: 222 } }),
|
||||
emptyMap,
|
||||
)
|
||||
expect(result.width).toBe(500)
|
||||
expect(result.height).toBe(300)
|
||||
})
|
||||
|
||||
it('defaults to 360x240 when neither source has dimensions', () => {
|
||||
const result = deserializeApiNode(makeApiNode({ type: 'groupRect' }), emptyMap)
|
||||
expect(result.width).toBe(360)
|
||||
expect(result.height).toBe(240)
|
||||
|
||||
@@ -48,6 +48,35 @@ describe('changedFactFields', () => {
|
||||
expect(changedFactFields(data({ properties: [] }), base)).toEqual(['properties'])
|
||||
})
|
||||
|
||||
it('does not call hiding a service an edit to the device', () => {
|
||||
const services = [
|
||||
{ port: 22, protocol: 'tcp' as const, service_name: 'ssh' },
|
||||
{ port: 3001, protocol: 'tcp' as const, service_name: 'Uptime Kuma' },
|
||||
]
|
||||
const base = factsBaselineOf(data({ services }))
|
||||
const hidden = [services[0], { ...services[1], visible: false }]
|
||||
// Visibility belongs to this node — pushing it as a device edit would hide
|
||||
// the service on every other canvas drawing the same row.
|
||||
expect(changedFactFields(data({ services: hidden }), base)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not call reordering an edit to the device', () => {
|
||||
const props = [
|
||||
{ key: 'Rack', value: 'A1', icon: null, visible: true },
|
||||
{ key: 'Owner', value: 'me', icon: null, visible: true },
|
||||
]
|
||||
const base = factsBaselineOf(data({ properties: props }))
|
||||
expect(changedFactFields(data({ properties: [props[1], props[0]] }), base)).toEqual([])
|
||||
})
|
||||
|
||||
it('still reports a real edit to a list item', () => {
|
||||
const props = [{ key: 'Rack', value: 'A1', icon: null, visible: true }]
|
||||
const base = factsBaselineOf(data({ properties: props }))
|
||||
expect(changedFactFields(data({ properties: [{ ...props[0], value: 'B2' }] }), base)).toEqual([
|
||||
'properties',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats every fact as changed with no baseline — a new node has nothing to revert', () => {
|
||||
expect(changedFactFields(data(), undefined)).toEqual([...DEVICE_FACT_FIELDS])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { pollImportJob, PollAbortedError, type ImportJobState } from '../importJobPoll'
|
||||
|
||||
interface Payload { device_count: number }
|
||||
|
||||
const done = (device_count: number): ImportJobState<Payload> => ({
|
||||
status: 'done',
|
||||
result: { device_count },
|
||||
})
|
||||
const running: ImportJobState<Payload> = { status: 'running', result: null }
|
||||
|
||||
/** Resolves instantly so no test waits on a real timer. */
|
||||
const noSleep = () => Promise.resolve()
|
||||
|
||||
describe('pollImportJob', () => {
|
||||
it('returns the result when the first poll is already done', async () => {
|
||||
const fetchJob = vi.fn().mockResolvedValue(done(2))
|
||||
await expect(pollImportJob(fetchJob, { sleep: noSleep })).resolves.toEqual({ device_count: 2 })
|
||||
expect(fetchJob).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps polling while the job is running', async () => {
|
||||
const fetchJob = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(running)
|
||||
.mockResolvedValueOnce(running)
|
||||
.mockResolvedValueOnce(done(7))
|
||||
|
||||
await expect(pollImportJob(fetchJob, { sleep: noSleep })).resolves.toEqual({ device_count: 7 })
|
||||
expect(fetchJob).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('waits the configured interval between polls', async () => {
|
||||
const sleep = vi.fn().mockResolvedValue(undefined)
|
||||
const fetchJob = vi.fn().mockResolvedValueOnce(running).mockResolvedValueOnce(done(1))
|
||||
|
||||
await pollImportJob(fetchJob, { intervalMs: 1234, sleep })
|
||||
expect(sleep).toHaveBeenCalledTimes(1)
|
||||
expect(sleep.mock.calls[0][0]).toBe(1234)
|
||||
})
|
||||
|
||||
it('propagates a rejected poll so the caller sees the backend status', async () => {
|
||||
const err = Object.assign(new Error('boom'), {
|
||||
response: { status: 504, data: { detail: 'Timed out' } },
|
||||
})
|
||||
const fetchJob = vi.fn().mockResolvedValueOnce(running).mockRejectedValueOnce(err)
|
||||
|
||||
await expect(pollImportJob(fetchJob, { sleep: noSleep })).rejects.toBe(err)
|
||||
})
|
||||
|
||||
it('rejects with PollAbortedError when aborted before the first poll', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const fetchJob = vi.fn().mockResolvedValue(done(1))
|
||||
|
||||
await expect(
|
||||
pollImportJob(fetchJob, { signal: controller.signal, sleep: noSleep }),
|
||||
).rejects.toBeInstanceOf(PollAbortedError)
|
||||
expect(fetchJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects with PollAbortedError when aborted while a poll is in flight', async () => {
|
||||
const controller = new AbortController()
|
||||
const fetchJob = vi.fn().mockImplementation(async () => {
|
||||
controller.abort()
|
||||
return running
|
||||
})
|
||||
|
||||
await expect(
|
||||
pollImportJob(fetchJob, { signal: controller.signal, sleep: noSleep }),
|
||||
).rejects.toBeInstanceOf(PollAbortedError)
|
||||
expect(fetchJob).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects when the job reports done with no payload', async () => {
|
||||
const fetchJob = vi.fn().mockResolvedValue({ status: 'done', result: null })
|
||||
await expect(pollImportJob(fetchJob, { sleep: noSleep })).rejects.toThrow(
|
||||
'Import finished without a result',
|
||||
)
|
||||
})
|
||||
|
||||
it('default sleep resolves after the interval and rejects on abort', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const fetchJob = vi.fn().mockResolvedValueOnce(running).mockResolvedValueOnce(done(1))
|
||||
const promise = pollImportJob(fetchJob, { intervalMs: 50 })
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
await expect(promise).resolves.toEqual({ device_count: 1 })
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborting = pollImportJob(
|
||||
vi.fn().mockResolvedValue(running),
|
||||
{ intervalMs: 50, signal: controller.signal },
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
controller.abort()
|
||||
await expect(aborting).rejects.toBeInstanceOf(PollAbortedError)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parsePortSpec, isValidPortSpec, countPorts, FULL_PORT_RANGE } from '../portSpec'
|
||||
|
||||
describe('parsePortSpec', () => {
|
||||
it('reads a single port, a range and a comma list', () => {
|
||||
expect(parsePortSpec('80')).toEqual([[80, 80]])
|
||||
expect(parsePortSpec('1-1024')).toEqual([[1, 1024]])
|
||||
expect(parsePortSpec('443,80')).toEqual([[80, 80], [443, 443]])
|
||||
})
|
||||
|
||||
it('merges overlapping and adjacent ranges', () => {
|
||||
expect(parsePortSpec('1-100,50-200')).toEqual([[1, 200]])
|
||||
expect(parsePortSpec('1-100,101-200')).toEqual([[1, 200]])
|
||||
expect(parsePortSpec('1-100,300-400')).toEqual([[1, 100], [300, 400]])
|
||||
})
|
||||
|
||||
it('tolerates whitespace around tokens', () => {
|
||||
expect(parsePortSpec(' 80 , 443 ')).toEqual([[80, 80], [443, 443]])
|
||||
})
|
||||
|
||||
it('rejects what nmap could not use', () => {
|
||||
for (const bad of ['', ' ', '0', '65536', '100-50', '80,', 'http', '80-', '-80', '1-2-3']) {
|
||||
expect(parsePortSpec(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidPortSpec / countPorts', () => {
|
||||
it('counts merged ranges once', () => {
|
||||
expect(countPorts('1-100,50-200')).toBe(200)
|
||||
expect(countPorts('80,443')).toBe(2)
|
||||
expect(countPorts(FULL_PORT_RANGE)).toBe(65535)
|
||||
})
|
||||
|
||||
it('counts an invalid spec as nothing', () => {
|
||||
expect(isValidPortSpec('nope')).toBe(false)
|
||||
expect(countPorts('nope')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ipToInt, parseCidr, isValidCidr, ipInSubnet, isZoneSubnetCandidate } from '@/utils/subnet'
|
||||
|
||||
describe('ipToInt', () => {
|
||||
it('converts a dotted quad', () => {
|
||||
expect(ipToInt('0.0.0.0')).toBe(0)
|
||||
expect(ipToInt('255.255.255.255')).toBe(4294967295)
|
||||
expect(ipToInt('192.168.1.42')).toBe(3232235818)
|
||||
})
|
||||
|
||||
it('tolerates surrounding whitespace', () => {
|
||||
expect(ipToInt(' 10.0.0.1 ')).toBe(ipToInt('10.0.0.1'))
|
||||
})
|
||||
|
||||
it('rejects malformed addresses', () => {
|
||||
for (const bad of ['', '1.2.3', '1.2.3.4.5', '256.0.0.1', '1.2.3.-1', 'a.b.c.d', '1.2.3.4/24', '::1']) {
|
||||
expect(ipToInt(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCidr', () => {
|
||||
it('masks the host bits off, so any address in the range parses to the network', () => {
|
||||
expect(parseCidr('192.168.1.42/24')).toEqual(parseCidr('192.168.1.0/24'))
|
||||
})
|
||||
|
||||
it('treats a bare address as /32', () => {
|
||||
expect(parseCidr('10.0.0.7')).toEqual({ base: ipToInt('10.0.0.7'), bits: 32 })
|
||||
})
|
||||
|
||||
it('handles /0 without the shift wrapping to -1', () => {
|
||||
expect(parseCidr('0.0.0.0/0')).toEqual({ base: 0, bits: 0 })
|
||||
expect(parseCidr('192.168.1.1/0')).toEqual({ base: 0, bits: 0 })
|
||||
})
|
||||
|
||||
it('rejects junk', () => {
|
||||
for (const bad of ['', ' ', '192.168.1.0/33', '192.168.1.0/x', '192.168.1.0/24/8', '192.168.1.0/', 'not-an-ip/24']) {
|
||||
expect(parseCidr(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects IPv6 — out of scope, so the UI can say why', () => {
|
||||
expect(parseCidr('2001:db8::/32')).toBeNull()
|
||||
expect(isValidCidr('2001:db8::/32')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ipInSubnet', () => {
|
||||
it('matches inside a /24 and rejects the neighbours', () => {
|
||||
expect(ipInSubnet('192.168.1.1', '192.168.1.0/24')).toBe(true)
|
||||
expect(ipInSubnet('192.168.1.255', '192.168.1.0/24')).toBe(true)
|
||||
expect(ipInSubnet('192.168.2.1', '192.168.1.0/24')).toBe(false)
|
||||
expect(ipInSubnet('192.168.0.255', '192.168.1.0/24')).toBe(false)
|
||||
})
|
||||
|
||||
it('honours wider and narrower prefixes', () => {
|
||||
expect(ipInSubnet('10.4.9.2', '10.0.0.0/8')).toBe(true)
|
||||
expect(ipInSubnet('11.4.9.2', '10.0.0.0/8')).toBe(false)
|
||||
expect(ipInSubnet('10.0.0.7', '10.0.0.7/32')).toBe(true)
|
||||
expect(ipInSubnet('10.0.0.8', '10.0.0.7/32')).toBe(false)
|
||||
expect(ipInSubnet('8.8.8.8', '0.0.0.0/0')).toBe(true)
|
||||
})
|
||||
|
||||
it('strips a prefix or port suffix off the node address', () => {
|
||||
expect(ipInSubnet('192.168.1.5/24', '192.168.1.0/24')).toBe(true)
|
||||
expect(ipInSubnet('192.168.1.5:8006', '192.168.1.0/24')).toBe(true)
|
||||
})
|
||||
|
||||
it('never matches a missing or unparseable address', () => {
|
||||
expect(ipInSubnet(undefined, '192.168.1.0/24')).toBe(false)
|
||||
expect(ipInSubnet(null, '192.168.1.0/24')).toBe(false)
|
||||
expect(ipInSubnet('', '192.168.1.0/24')).toBe(false)
|
||||
expect(ipInSubnet('fe80::1', '192.168.1.0/24')).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches against an invalid CIDR', () => {
|
||||
expect(ipInSubnet('192.168.1.5', 'nonsense')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isZoneSubnetCandidate', () => {
|
||||
const node = (over: Partial<{ id: string; parentId?: string; type: string; ip?: string }> = {}) => ({
|
||||
id: over.id ?? 'n1',
|
||||
parentId: over.parentId,
|
||||
data: { type: over.type ?? 'server', ip: 'ip' in over ? over.ip : '192.168.1.5' },
|
||||
})
|
||||
|
||||
it('accepts a free, addressed device in range', () => {
|
||||
expect(isZoneSubnetCandidate(node(), '192.168.1.0/24', 'z1')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves an already-parented node with the parent the user gave it', () => {
|
||||
expect(isZoneSubnetCandidate(node({ parentId: 'g1' }), '192.168.1.0/24', 'z1')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips canvas furniture', () => {
|
||||
for (const type of ['groupRect', 'group', 'text']) {
|
||||
expect(isZoneSubnetCandidate(node({ type }), '192.168.1.0/24', 'z1')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('never treats the zone itself as a candidate', () => {
|
||||
expect(isZoneSubnetCandidate(node({ id: 'z1', type: 'server' }), '192.168.1.0/24', 'z1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range or address-less node', () => {
|
||||
expect(isZoneSubnetCandidate(node({ ip: '10.0.0.1' }), '192.168.1.0/24', 'z1')).toBe(false)
|
||||
expect(isZoneSubnetCandidate(node({ ip: undefined }), '192.168.1.0/24', 'z1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,18 @@ export interface ApiEdge {
|
||||
|
||||
// ── Serialization (RF node → API save payload) ───────────────────────────────
|
||||
|
||||
/** Drop the legacy geometry keys from a zone's style blob — its size lives in
|
||||
* the `width`/`height` columns now, and a stale copy here would be the one an
|
||||
* older canvas reads back. */
|
||||
function omitZoneSize(
|
||||
colors: NodeData['custom_colors'],
|
||||
): Record<string, unknown> {
|
||||
if (!colors) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(colors).filter(([key]) => key !== 'width' && key !== 'height'),
|
||||
)
|
||||
}
|
||||
|
||||
export function serializeNode(
|
||||
n: Node<NodeData>,
|
||||
factsBaseline?: FactsBaseline,
|
||||
@@ -89,10 +101,14 @@ export function serializeNode(
|
||||
custom_icon: null,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
// A zone's size goes in the real columns, like every other node type.
|
||||
// It used to live in the custom_colors blob; `width`/`height` are
|
||||
// stripped from it below so the two cannot drift apart, and a canvas
|
||||
// saved before the change is migrated by `_backfill_zone_size`.
|
||||
width: n.width ?? n.measured?.width ?? 360,
|
||||
height: n.height ?? n.measured?.height ?? 240,
|
||||
custom_colors: {
|
||||
...n.data.custom_colors,
|
||||
width: n.width ?? n.measured?.width ?? 360,
|
||||
height: n.height ?? n.measured?.height ?? 240,
|
||||
...omitZoneSize(n.data.custom_colors),
|
||||
// Stash collapse state inside custom_colors so the API/YAML blob does
|
||||
// not need a new column. Hoisted back to `data.collapsed` on load.
|
||||
collapsed: n.data.collapsed ?? false,
|
||||
@@ -182,8 +198,11 @@ export function deserializeApiNode(
|
||||
): Node<NodeData> {
|
||||
const normalizedType = n.type === 'docker' ? 'docker_host' : n.type
|
||||
if (n.type === 'groupRect') {
|
||||
const w = (n.custom_colors?.width as number | undefined) ?? 360
|
||||
const h = (n.custom_colors?.height as number | undefined) ?? 240
|
||||
// Prefer the real columns; fall back to the custom_colors stash for a
|
||||
// canvas saved before the size moved out of it, and which the backend
|
||||
// backfill has not reached (a payload from an older server, an import).
|
||||
const w = n.width ?? (n.custom_colors?.width as number | undefined) ?? 360
|
||||
const h = n.height ?? (n.custom_colors?.height as number | undefined) ?? 240
|
||||
const z = (n.custom_colors?.z_order as number | undefined) ?? 1
|
||||
// Hoist persisted collapse flag from the custom_colors stash to a
|
||||
// first-class field on NodeData. Tolerates legacy saves that already had
|
||||
|
||||
@@ -40,9 +40,35 @@ export type FactsBaseline = Record<string, string>
|
||||
|
||||
const encode = (value: unknown): string => JSON.stringify(value ?? null)
|
||||
|
||||
/**
|
||||
* Which services and properties the node carries, ignoring how it draws them.
|
||||
*
|
||||
* Order and visibility belong to the node, not to the inventory row, so hiding
|
||||
* a service or dragging a property into place is not an edit to the device and
|
||||
* must not be reported as one — otherwise a rearranged canvas would push its
|
||||
* arrangement onto every other canvas showing the same device.
|
||||
*/
|
||||
const keyOf = (item: Record<string, unknown>): string =>
|
||||
'key' in item
|
||||
? String(item.key ?? '').toLowerCase()
|
||||
: `${item.port ?? ''}|${item.protocol ?? ''}|${String(item.service_name ?? '').toLowerCase()}`
|
||||
|
||||
const encodeFacts = (field: DeviceFactField, value: unknown): string => {
|
||||
if (field !== 'services' && field !== 'properties') return encode(value)
|
||||
const items = (Array.isArray(value) ? value : []).filter(
|
||||
(item): item is Record<string, unknown> => typeof item === 'object' && item !== null,
|
||||
)
|
||||
const bare = items.map((item) => {
|
||||
const rest = { ...item }
|
||||
delete rest.visible
|
||||
return rest
|
||||
})
|
||||
return encode([...bare].sort((a, b) => keyOf(a).localeCompare(keyOf(b))))
|
||||
}
|
||||
|
||||
export function factsBaselineOf(data: Partial<NodeData>): FactsBaseline {
|
||||
const out: FactsBaseline = {}
|
||||
for (const field of DEVICE_FACT_FIELDS) out[field] = encode(data[field])
|
||||
for (const field of DEVICE_FACT_FIELDS) out[field] = encodeFacts(field, data[field])
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -64,7 +90,37 @@ export function changedFactFields(
|
||||
baseline: FactsBaseline | undefined,
|
||||
): DeviceFactField[] {
|
||||
if (!baseline) return [...DEVICE_FACT_FIELDS]
|
||||
return DEVICE_FACT_FIELDS.filter((field) => encode(data[field]) !== baseline[field])
|
||||
return DEVICE_FACT_FIELDS.filter((field) => encodeFacts(field, data[field]) !== baseline[field])
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's list, arranged the way this node already draws it.
|
||||
*
|
||||
* The facts are the row's, the order and the visibility are the node's, so an
|
||||
* edit made in the Device Inventory must not reshuffle a canvas or unhide what
|
||||
* it hid. Same rule the backend applies on read: what the node already lists
|
||||
* keeps its place and its flag, what the row gained since is appended hidden,
|
||||
* and what the row lost disappears.
|
||||
*/
|
||||
export function listArrangedForNode<T extends Record<string, unknown>>(
|
||||
current: T[] | undefined,
|
||||
incoming: T[],
|
||||
): T[] {
|
||||
if (!current?.length) return incoming
|
||||
const seen = new Set<string>()
|
||||
const by = new Map(incoming.map((item) => [keyOf(item), item]))
|
||||
const out: T[] = []
|
||||
for (const item of current) {
|
||||
const key = keyOf(item)
|
||||
const fresh = by.get(key)
|
||||
if (!fresh || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push('visible' in item ? { ...fresh, visible: item.visible } : (fresh as T))
|
||||
}
|
||||
for (const item of incoming) {
|
||||
if (!seen.has(keyOf(item))) out.push({ ...item, visible: false })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Polling helper for backend import jobs that answer immediately and finish later.
|
||||
*
|
||||
* A Zigbee canvas import has to fetch a Z2M networkmap, which on a large mesh
|
||||
* takes minutes — longer than the read timeout of any reverse proxy in front of
|
||||
* the API. The backend therefore returns a job id and does the work in the
|
||||
* background; the client polls short requests until the payload is ready.
|
||||
*/
|
||||
|
||||
export class PollAbortedError extends Error {
|
||||
constructor() {
|
||||
super('Import polling aborted')
|
||||
this.name = 'PollAbortedError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImportJobState<T> {
|
||||
status: string
|
||||
result: T | null
|
||||
}
|
||||
|
||||
export interface PollImportJobOptions {
|
||||
/** Delay between polls, in ms. */
|
||||
intervalMs?: number
|
||||
/** Abort the loop (modal closed, component unmounted). */
|
||||
signal?: AbortSignal
|
||||
/** Injected in tests so no timer actually runs. */
|
||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number, signal?: AbortSignal) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new PollAbortedError())
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Poll `fetchJob` until it reports a terminal status, then resolve its result.
|
||||
*
|
||||
* A failed job surfaces as a rejected `fetchJob` call (the backend replays the
|
||||
* original status code), so errors propagate untouched to the caller. Rejects
|
||||
* with `PollAbortedError` if the signal fires, and with a plain Error if the
|
||||
* job reports done without a payload.
|
||||
*/
|
||||
export async function pollImportJob<T>(
|
||||
fetchJob: () => Promise<ImportJobState<T>>,
|
||||
{ intervalMs = 2000, signal, sleep = defaultSleep }: PollImportJobOptions = {},
|
||||
): Promise<T> {
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new PollAbortedError()
|
||||
|
||||
const job = await fetchJob()
|
||||
if (signal?.aborted) throw new PollAbortedError()
|
||||
|
||||
if (job.status !== 'running') {
|
||||
if (job.result == null) throw new Error('Import finished without a result')
|
||||
return job.result
|
||||
}
|
||||
|
||||
await sleep(intervalMs, signal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* nmap-style port specs — `80`, `1-1024`, or a comma list of both.
|
||||
*
|
||||
* Mirrors `_parse_port_spec` in `backend/app/services/scanner.py`: the deep-scan
|
||||
* dialog validates here so a typo is caught before a request, and the backend
|
||||
* validates again because it is the one handing the spec to nmap.
|
||||
*/
|
||||
|
||||
export const FULL_PORT_RANGE = '1-65535'
|
||||
|
||||
const TOKEN_RE = /^\d{1,5}(-\d{1,5})?$/
|
||||
|
||||
/**
|
||||
* Sorted, merged `[start, end]` ranges, or `null` when the spec is unusable.
|
||||
* An empty spec is unusable too — a scan of nothing is never what was meant.
|
||||
*/
|
||||
export function parsePortSpec(spec: string): Array<[number, number]> | null {
|
||||
const tokens = spec.split(',').map((t) => t.trim())
|
||||
if (tokens.length === 0 || tokens.some((t) => t === '')) return null
|
||||
|
||||
const ranges: Array<[number, number]> = []
|
||||
for (const token of tokens) {
|
||||
if (!TOKEN_RE.test(token)) return null
|
||||
const parts = token.split('-').map(Number)
|
||||
const start = parts[0]
|
||||
const end = parts[parts.length - 1]
|
||||
if (start < 1 || end > 65535 || start > end) return null
|
||||
ranges.push([start, end])
|
||||
}
|
||||
|
||||
ranges.sort((a, b) => a[0] - b[0])
|
||||
const merged: Array<[number, number]> = [ranges[0]]
|
||||
for (const [start, end] of ranges.slice(1)) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (start <= last[1] + 1) last[1] = Math.max(last[1], end)
|
||||
else merged.push([start, end])
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function isValidPortSpec(spec: string): boolean {
|
||||
return parsePortSpec(spec) !== null
|
||||
}
|
||||
|
||||
/** How many ports a spec covers — 0 when it is invalid. */
|
||||
export function countPorts(spec: string): number {
|
||||
const ranges = parsePortSpec(spec)
|
||||
if (!ranges) return 0
|
||||
return ranges.reduce((sum, [start, end]) => sum + (end - start + 1), 0)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* IPv4 subnet matching, used by the zone "Import devices by subnet" action.
|
||||
*
|
||||
* IPv4 only on purpose: a homelab zone is drawn around a LAN segment, and the
|
||||
* canvas has no IPv6 grouping story yet. `isValidCidr` rejects an IPv6 CIDR so
|
||||
* the modal can say why instead of silently matching nothing.
|
||||
*/
|
||||
|
||||
/** A parsed CIDR: the network address as a 32-bit int, plus the prefix length. */
|
||||
export interface ParsedCidr {
|
||||
base: number
|
||||
bits: number
|
||||
}
|
||||
|
||||
/**
|
||||
* "192.168.1.42" → 3232235818. Null for anything that is not four decimal
|
||||
* octets in 0..255 — no leading zeros, no shorthand.
|
||||
*/
|
||||
export function ipToInt(ip: string): number | null {
|
||||
const parts = ip.trim().split('.')
|
||||
if (parts.length !== 4) return null
|
||||
let acc = 0
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return null
|
||||
const n = Number(part)
|
||||
if (n > 255) return null
|
||||
acc = acc * 256 + n
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse "192.168.1.0/24". The host bits of the given address are masked off, so
|
||||
* "192.168.1.42/24" and "192.168.1.0/24" parse to the same network.
|
||||
*
|
||||
* A bare address with no "/" is treated as /32 — one host.
|
||||
*/
|
||||
export function parseCidr(cidr: string): ParsedCidr | null {
|
||||
const trimmed = cidr.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const [addr, prefix, ...rest] = trimmed.split('/')
|
||||
if (rest.length > 0) return null
|
||||
|
||||
const ip = ipToInt(addr)
|
||||
if (ip === null) return null
|
||||
|
||||
let bits = 32
|
||||
if (prefix !== undefined) {
|
||||
if (!/^\d{1,2}$/.test(prefix)) return null
|
||||
bits = Number(prefix)
|
||||
if (bits > 32) return null
|
||||
}
|
||||
|
||||
// A /0 mask would be `-1 << 32`, which JS evaluates as `-1 << 0` = -1 — the
|
||||
// shift count wraps mod 32. Special-cased so "0.0.0.0/0" matches everything.
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
||||
return { base: (ip & mask) >>> 0, bits }
|
||||
}
|
||||
|
||||
export function isValidCidr(cidr: string): boolean {
|
||||
return parseCidr(cidr) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `ip` fall inside `cidr`?
|
||||
*
|
||||
* `ip` comes straight off node data, so it may carry a prefix ("10.0.0.5/24")
|
||||
* or a port ("10.0.0.5:8006"); both suffixes are stripped. Missing or
|
||||
* unparseable addresses never match.
|
||||
*/
|
||||
export function ipInSubnet(ip: string | null | undefined, cidr: string): boolean {
|
||||
const parsed = parseCidr(cidr)
|
||||
if (!parsed) return false
|
||||
if (!ip) return false
|
||||
|
||||
const bare = ip.trim().split('/')[0].split(':')[0]
|
||||
const value = ipToInt(bare)
|
||||
if (value === null) return false
|
||||
|
||||
const mask = parsed.bits === 0 ? 0 : (0xffffffff << (32 - parsed.bits)) >>> 0
|
||||
return ((value & mask) >>> 0) === parsed.base
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule the zone subnet import runs on, shared by the store action and the
|
||||
* modal's match-count preview so the number shown is the number that moves.
|
||||
*
|
||||
* A candidate must be free (no parent — a node already nested in a group, a
|
||||
* container host or another zone keeps the parent the user gave it), must not
|
||||
* be canvas furniture (which describes nothing physical and so has no IP worth
|
||||
* matching), and must have an IP inside the range.
|
||||
*/
|
||||
const FURNITURE_TYPES = new Set(['groupRect', 'group', 'text'])
|
||||
|
||||
export function isZoneSubnetCandidate(
|
||||
node: { id: string; parentId?: string; data: { type: string; ip?: string } },
|
||||
cidr: string,
|
||||
zoneId?: string,
|
||||
): boolean {
|
||||
if (node.id === zoneId) return false
|
||||
if (node.parentId) return false
|
||||
if (FURNITURE_TYPES.has(node.data.type)) return false
|
||||
return ipInSubnet(node.data.ip, cidr)
|
||||
}
|
||||
Reference in New Issue
Block a user