Files
homelable/backend/app/api/routes/status.py
Pouzor 9b77e43ca1 feat(inventory): drop the device columns from nodes
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
2026-08-14 18:02:02 +02:00

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,
}))