feat(api): stats endpoint for gethomepage widget (closes #131)
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.
This commit is contained in:
@@ -28,3 +28,9 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# Off by default. Set to a random secret to enable.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# LIVEVIEW_KEY=
|
||||
|
||||
# Gethomepage widget — read-only stats at /api/v1/stats/summary
|
||||
# Off by default. Set to a random secret to enable; clients must send
|
||||
# the same value in the `X-API-Key` header.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# HOMEPAGE_API_KEY=
|
||||
|
||||
@@ -134,6 +134,60 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
||||
|
||||
---
|
||||
|
||||
## Gethomepage Widget (read-only stats)
|
||||
|
||||
Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
|
||||
|
||||
### Activation
|
||||
|
||||
Add `HOMEPAGE_API_KEY` to your `.env`:
|
||||
|
||||
`HOMEPAGE_API_KEY=your-secret-key`
|
||||
|
||||
Restart the backend (`docker compose restart backend`).
|
||||
|
||||
### Endpoint
|
||||
|
||||
`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": 12,
|
||||
"online": 9,
|
||||
"offline": 2,
|
||||
"unknown": 1,
|
||||
"pending_devices": 3,
|
||||
"zigbee_devices": 5,
|
||||
"last_scan_at": "2026-05-14T10:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### gethomepage `services.yaml` snippet
|
||||
|
||||
```yaml
|
||||
- Homelab:
|
||||
- Homelable:
|
||||
icon: mdi-lan
|
||||
href: http://homelable.local:3000
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://homelable.local:8000/api/v1/stats/summary
|
||||
method: GET
|
||||
headers:
|
||||
X-API-Key: your-secret-key
|
||||
mappings:
|
||||
- field: nodes ; label: Nodes
|
||||
- field: online ; label: Online
|
||||
- field: offline ; label: Offline
|
||||
- field: pending_devices ; label: Pending
|
||||
- field: zigbee_devices ; label: Zigbee
|
||||
- field: last_scan_at ; label: Last scan
|
||||
```
|
||||
|
||||
The backend port (`8000`) must be reachable from your gethomepage container.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (AI Integration) (optional)
|
||||
|
||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
}
|
||||
@@ -61,6 +61,11 @@ class Settings(BaseSettings):
|
||||
# Leave unset (or empty) to keep the feature disabled (default).
|
||||
liveview_key: str | None = None
|
||||
|
||||
# Homepage widget — optional read-only stats endpoint for gethomepage.
|
||||
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
|
||||
# Leave empty to keep the feature disabled (default).
|
||||
homepage_api_key: str = ""
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status, zigbee
|
||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
@@ -56,6 +56,7 @@ app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""API tests for /api/v1/stats/* (gethomepage widget)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_homepage_key():
|
||||
original = settings.homepage_api_key
|
||||
settings.homepage_api_key = ""
|
||||
yield
|
||||
settings.homepage_api_key = original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
|
||||
res = await client.get("/api/v1/stats/summary")
|
||||
assert res.status_code == 403
|
||||
assert "disabled" in res.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get("/api/v1/stats/summary")
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_empty_db(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body == {
|
||||
"nodes": 0,
|
||||
"online": 0,
|
||||
"offline": 0,
|
||||
"unknown": 0,
|
||||
"pending_devices": 0,
|
||||
"zigbee_devices": 0,
|
||||
"last_scan_at": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_aggregates_counts(
|
||||
client: AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
||||
db_session.add_all([
|
||||
Node(type="server", label="A", status="online"),
|
||||
Node(type="server", label="B", status="online"),
|
||||
Node(type="server", label="C", status="offline"),
|
||||
Node(type="server", label="D", status="unknown"),
|
||||
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
||||
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
||||
PendingDevice(ip="10.0.0.1", status="pending"),
|
||||
PendingDevice(ip="10.0.0.2", status="pending"),
|
||||
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
|
||||
ScanRun(status="success", finished_at=finished),
|
||||
ScanRun(status="success",
|
||||
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
|
||||
])
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["nodes"] == 6
|
||||
assert body["online"] == 4
|
||||
assert body["offline"] == 1
|
||||
assert body["unknown"] == 1
|
||||
assert body["pending_devices"] == 2
|
||||
assert body["zigbee_devices"] == 2
|
||||
# SQLite returns naive datetimes; compare prefix only.
|
||||
assert body["last_scan_at"] is not None
|
||||
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
|
||||
Reference in New Issue
Block a user