Completes the split: `nodes` now holds only how a device is drawn on one canvas, and every device fact reaches the API from the inventory row. - The device columns are removed from `nodes` (SQLite table rebuild, the same shape as the existing device_inventory and canvas_state rebuilds). The drop is skipped, and logged, while any non-furniture node is still unlinked: those columns are the last copy of that node's facts. The backfill therefore reads them with raw SQL — by the time it runs, the model no longer declares them. - The status checker iterates devices, not nodes: one check per device however many canvases draw it, writing `status_live` / `last_seen` / `response_time_ms` on the row. `/ws/status` messages carry `device_id` and the node ids they light up. Hidden devices are not probed. - Readers repointed: scanner (last_scan lands on the row), proxmox (its node tier collapses into the inventory tier, keeping only the cluster handles), zigbee/zwave (one property refresh serves every canvas), rack inventory, liveview, stats, node dedupe. - `POST /scan/pending` merges into the row that already describes the host instead of minting a second one — one device is one row, whichever way it was documented. - Standalone keeps parity: the canvas blob gains `devices`, split on save and hydrated on load. A blob written before the split still reads. The rack inventory had a related bug: a mount that names a node explicitly printed the mount's device rather than the pinned node's. It now reads the node's own row. Tests that built a node with device columns are ported to the link; where a behaviour genuinely moved (properties refresh once on the row, last_scan is the device's) the assertion moved with it rather than being dropped. ha-relevant: yes
115 lines
3.4 KiB
Python
115 lines
3.4 KiB
Python
import contextlib
|
|
import json
|
|
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
from app.core.config import settings
|
|
from app.core.security import (
|
|
decode_oidc_session_token,
|
|
decode_token,
|
|
oidc_session_cookie_name,
|
|
origin_is_allowed,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
# Active WebSocket connections
|
|
_connections: list[WebSocket] = []
|
|
|
|
|
|
def _drop(websocket: WebSocket) -> None:
|
|
"""Remove a connection if still present — idempotent, never raises."""
|
|
with contextlib.suppress(ValueError):
|
|
_connections.remove(websocket)
|
|
|
|
|
|
@router.websocket("/ws/status")
|
|
async def ws_status(websocket: WebSocket) -> None:
|
|
# Accept first so we can send a close frame with a reason code
|
|
await websocket.accept()
|
|
if not origin_is_allowed(websocket.headers.get("origin")):
|
|
await websocket.close(code=1008) # Policy Violation
|
|
return
|
|
|
|
if settings.auth_mode == "oidc":
|
|
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
|
|
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
|
|
await websocket.close(code=1008) # Policy Violation
|
|
return
|
|
else:
|
|
# Local mode keeps the existing first-message bearer protocol.
|
|
try:
|
|
raw = await websocket.receive_text()
|
|
try:
|
|
payload = json.loads(raw)
|
|
token = payload.get("token", "")
|
|
except (json.JSONDecodeError, AttributeError):
|
|
token = ""
|
|
if not token or not decode_token(token):
|
|
await websocket.close(code=1008) # Policy Violation
|
|
return
|
|
except WebSocketDisconnect:
|
|
return
|
|
|
|
_connections.append(websocket)
|
|
try:
|
|
while True:
|
|
await websocket.receive_text()
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
# Any error (disconnect or otherwise) must release the slot, else the
|
|
# dead socket lingers in the broadcast pool.
|
|
_drop(websocket)
|
|
|
|
|
|
async def _broadcast(payload: str) -> None:
|
|
for conn in list(_connections):
|
|
try:
|
|
await conn.send_text(payload)
|
|
except Exception:
|
|
_drop(conn)
|
|
|
|
|
|
async def broadcast_status(
|
|
device_id: str,
|
|
node_ids: list[str],
|
|
status: str,
|
|
checked_at: str,
|
|
response_time_ms: int | None = None,
|
|
) -> None:
|
|
"""Report one device's reachability to every canvas drawing it.
|
|
|
|
A device is checked once, not once per canvas, so the result is addressed by
|
|
device and carries the nodes it lights up. ``node_ids`` is empty for a
|
|
device that is in the inventory but on no canvas.
|
|
"""
|
|
await _broadcast(json.dumps({
|
|
"type": "status",
|
|
"device_id": device_id,
|
|
"node_ids": node_ids,
|
|
"status": status,
|
|
"checked_at": checked_at,
|
|
"response_time_ms": response_time_ms,
|
|
}))
|
|
|
|
|
|
async def broadcast_service_status(
|
|
device_id: str, node_ids: list[str], services: list[dict[str, object]], checked_at: str
|
|
) -> None:
|
|
await _broadcast(json.dumps({
|
|
"type": "service_status",
|
|
"device_id": device_id,
|
|
"node_ids": node_ids,
|
|
"services": services,
|
|
"checked_at": checked_at,
|
|
}))
|
|
|
|
|
|
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
|
await _broadcast(json.dumps({
|
|
"type": "scan_device_found",
|
|
"run_id": run_id,
|
|
"devices_found": devices_found,
|
|
}))
|