994ed9d77a
Adds GET /api/v1/stats/summary, gated by HOMEPAGE_API_KEY env var and X-API-Key header (hmac.compare_digest, matches the liveview pattern). Payload: nodes / online / offline / unknown pending_devices (status='pending') zigbee_devices (Node.ieee_address IS NOT NULL) last_scan_at (max ScanRun.finished_at) Disabled by default — endpoint returns 403 unless HOMEPAGE_API_KEY is set. README documents activation and ships a ready-to-paste gethomepage `customapi` widget snippet. Tests cover: disabled-by-default, missing header, wrong key, empty DB, and full aggregation across nodes/pending/zigbee/scan-runs.
65 lines
2.0 KiB
Python
65 lines
2.0 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 Node, PendingDevice, 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)
|
|
|
|
status_rows = (
|
|
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
|
).all()
|
|
counts = {row[0]: row[1] for row in status_rows}
|
|
|
|
pending = (
|
|
await db.execute(
|
|
select(func.count())
|
|
.select_from(PendingDevice)
|
|
.where(PendingDevice.status == "pending")
|
|
)
|
|
).scalar_one()
|
|
|
|
zigbee = (
|
|
await db.execute(
|
|
select(func.count()).select_from(Node).where(Node.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),
|
|
"pending_devices": pending,
|
|
"zigbee_devices": zigbee,
|
|
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
|
}
|