diff --git a/.env.example b/.env.example index de4d90c..52fa748 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 0229938..8b5ada9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/api/routes/stats.py b/backend/app/api/routes/stats.py new file mode 100644 index 0000000..e3979f6 --- /dev/null +++ b/backend/app/api/routes/stats.py @@ -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, + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 481b7bd..3eb3882 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index b954546..8f0952f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/tests/test_stats.py b/backend/tests/test_stats.py new file mode 100644 index 0000000..77e033a --- /dev/null +++ b/backend/tests/test_stats.py @@ -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")