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
83 lines
2.8 KiB
Python
83 lines
2.8 KiB
Python
import hmac
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.db.database import get_db
|
|
from app.db.models import InventoryDevice, Node, ScanRun
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _check_key(x_api_key: str | None) -> None:
|
|
if not settings.homepage_api_key:
|
|
raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
|
|
if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
|
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
|
|
|
|
|
@router.get("/summary")
|
|
async def summary(
|
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict[str, object]:
|
|
"""Read-only stats payload for the gethomepage `customapi` widget.
|
|
|
|
Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
|
|
value in the `X-API-Key` header.
|
|
"""
|
|
_check_key(x_api_key)
|
|
|
|
# A node's reachability lives on the Device Inventory row it draws, so the
|
|
# per-canvas counts come from the join. A node with no row (canvas
|
|
# furniture) counts as unknown.
|
|
status_rows = (
|
|
await db.execute(
|
|
select(InventoryDevice.status_live, func.count())
|
|
.select_from(Node)
|
|
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
|
.group_by(InventoryDevice.status_live)
|
|
)
|
|
).all()
|
|
counts = {(row[0] or "unknown"): row[1] for row in status_rows}
|
|
unlinked = (
|
|
await db.execute(select(func.count()).select_from(Node).where(Node.device_id.is_(None)))
|
|
).scalar_one()
|
|
if unlinked:
|
|
counts["unknown"] = counts.get("unknown", 0) + unlinked
|
|
|
|
pending = (
|
|
await db.execute(
|
|
select(func.count())
|
|
.select_from(InventoryDevice)
|
|
.where(InventoryDevice.status == "pending")
|
|
)
|
|
).scalar_one()
|
|
|
|
zigbee = (
|
|
await db.execute(
|
|
select(func.count())
|
|
.select_from(Node)
|
|
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
|
.where(InventoryDevice.ieee_address.isnot(None))
|
|
)
|
|
).scalar_one()
|
|
|
|
last_scan_at = (
|
|
await db.execute(select(func.max(ScanRun.finished_at)))
|
|
).scalar_one()
|
|
|
|
return {
|
|
"nodes": sum(counts.values()),
|
|
"online": counts.get("online", 0),
|
|
"offline": counts.get("offline", 0),
|
|
"unknown": counts.get("unknown", 0),
|
|
# Wire key kept from before the table was renamed to `device_inventory`:
|
|
# the stats payload is a published contract, and the MCP server reads it.
|
|
"pending_devices": pending,
|
|
"zigbee_devices": zigbee,
|
|
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
|
}
|