+
}
+ accent="emerald"
+ />
+
}
+ accent="amber"
+ />
}
accent="primary"
/>
-
}
- accent="emerald"
- />
}
accent="violet"
/>
@@ -48,14 +76,55 @@ function Home() {
-
- {t(keys.dashboard.home.welcome_card_title)}
+
+
+ {t(keys.dashboard.home.system_info_title)}
- {welcome}
-
-
- {t(keys.dashboard.home.description_body)}
+
+
+
+ {t(keys.dashboard.home.system_info.modules)}
+
+
+ {props.system_info.modules.map((mod) => (
+
+
+ {mod.name}
+
+ ))}
+
+
+
+
+
+
+
+ {t(keys.dashboard.home.system_info.python_version)}
+
+ {props.system_info.python_version}
+
+ {props.system_info.health_checks.map((check) => (
+
+
+
+ {check.name}
+
+
+
+
+ {check.status}
+
+
+
+ ))}
+
+
@@ -71,9 +140,9 @@ function StatCard({
title: string;
value: string;
icon: React.ReactNode;
- accent: string;
+ accent: Accent;
}) {
- const styles: Record
= {
+ const styles: Record = {
primary: {
card: 'border-primary-200 bg-gradient-to-br from-primary-50 to-card',
icon: 'text-primary-500 bg-primary-100',
@@ -89,9 +158,14 @@ function StatCard({
icon: 'text-violet-icon-fg bg-violet-icon-bg',
value: 'text-violet-value',
},
+ amber: {
+ card: 'border-amber-200 bg-gradient-to-br from-amber-50 to-card',
+ icon: 'text-amber-600 bg-amber-100',
+ value: 'text-amber-900',
+ },
};
- const s = styles[accent] || styles.primary;
+ const s = styles[accent];
return (
diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py
new file mode 100644
index 00000000..7ca42454
--- /dev/null
+++ b/modules/dashboard/dashboard/stats.py
@@ -0,0 +1,117 @@
+"""Dashboard statistics queries with TTL-based caching."""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+import time
+from datetime import UTC, datetime, timedelta
+
+from fastapi import FastAPI
+from products.models import Product
+from simple_module_core.health import HealthCheck, HealthStatus
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from users.models import User
+
+_CACHE_TTL_SECONDS = 30
+_cache: dict | None = None
+_cache_ts: float = 0.0
+_cache_lock = asyncio.Lock()
+
+
+def _cache_hit() -> dict | None:
+ if _cache is not None and (time.monotonic() - _cache_ts) < _CACHE_TTL_SECONDS:
+ return _cache.copy()
+ return None
+
+
+async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict:
+ """Gather all dashboard statistics, cached for 30 seconds."""
+ global _cache, _cache_ts
+
+ hit = _cache_hit()
+ if hit is not None:
+ return hit
+
+ async with _cache_lock:
+ # Re-check after acquiring lock — another coroutine may have refreshed.
+ hit = _cache_hit()
+ if hit is not None:
+ return hit
+
+ total_users = await _count_users(db)
+ active_users_7d = await _count_active_users(db, days=7)
+ total_products = await _count_products(db)
+ modules_list = _get_module_info(app)
+ health_checks = await _run_health_checks(app)
+
+ result = {
+ "total_users": total_users,
+ "active_users_7d": active_users_7d,
+ "total_products": total_products,
+ "module_count": len(modules_list),
+ "system_info": {
+ "modules": modules_list,
+ "python_version": (
+ f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
+ ),
+ "health_checks": health_checks,
+ },
+ }
+
+ _cache = result
+ _cache_ts = time.monotonic()
+ return result.copy()
+
+
+def invalidate_stats_cache() -> None:
+ """Clear the stats cache — useful for testing or after data mutations."""
+ global _cache, _cache_ts
+ _cache = None
+ _cache_ts = 0.0
+
+
+async def _count_users(db: AsyncSession) -> int:
+ result = await db.execute(select(func.count()).select_from(User))
+ return result.scalar_one()
+
+
+async def _count_active_users(db: AsyncSession, *, days: int) -> int:
+ cutoff = datetime.now(UTC) - timedelta(days=days)
+ result = await db.execute(
+ select(func.count()).select_from(User).where(User.last_login_at >= cutoff)
+ )
+ return result.scalar_one()
+
+
+async def _count_products(db: AsyncSession) -> int:
+ result = await db.execute(
+ select(func.count()).select_from(Product).where(Product.is_active.is_(True))
+ )
+ return result.scalar_one()
+
+
+def _get_module_info(app: FastAPI) -> list[dict[str, str]]:
+ # Reads from the module list discovered once at startup, avoiding
+ # expensive entry-point rescans on every request.
+ modules = getattr(app.state, "modules", None)
+ if modules is None:
+ return []
+ return [{"name": m.meta.name, "status": "loaded"} for m in modules]
+
+
+async def _run_health_checks(app: FastAPI) -> list[dict[str, str]]:
+ registry = app.state.health_registry
+ checks = registry.all_checks
+ if not checks:
+ return []
+
+ async def _run_one(check: HealthCheck) -> dict[str, str]:
+ try:
+ result = await check.check()
+ return {"name": check.name, "status": result.status.value}
+ except Exception:
+ return {"name": check.name, "status": HealthStatus.UNHEALTHY.value}
+
+ return list(await asyncio.gather(*[_run_one(c) for c in checks]))
diff --git a/modules/dashboard/pyproject.toml b/modules/dashboard/pyproject.toml
index d924568c..a9824cd7 100644
--- a/modules/dashboard/pyproject.toml
+++ b/modules/dashboard/pyproject.toml
@@ -11,6 +11,7 @@ dependencies = [
"simple-module-db",
"simple-module-hosting",
"products",
+ "users",
]
[project.entry-points.simple_module]
@@ -30,3 +31,4 @@ simple-module-core = { workspace = true }
simple-module-db = { workspace = true }
simple-module-hosting = { workspace = true }
products = { workspace = true }
+users = { workspace = true }
diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py
index 45e05f0f..1cf23e55 100644
--- a/modules/dashboard/tests/test_dashboard.py
+++ b/modules/dashboard/tests/test_dashboard.py
@@ -1,73 +1,19 @@
-"""Tests for the Dashboard module: event handlers, stats endpoint, and
-end-to-end event-bus wiring between Products and Dashboard."""
+"""Tests for the Dashboard module: stats endpoint and module registration."""
from __future__ import annotations
import httpx
import pytest
-from dashboard.handlers import (
- get_product_event_counts,
- on_product_created,
- on_product_deleted,
- on_product_updated,
- reset_product_event_counts,
-)
from dashboard.module import DashboardModule
-from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated
-from simple_module_core.events import EventBus
+from dashboard.stats import invalidate_stats_cache
@pytest.fixture(autouse=True)
-def _reset_counts():
- """Ensure every test starts with zeroed product event counters."""
- reset_product_event_counts()
+def _clear_stats_cache():
+ """Ensure each test gets fresh stats, not a cached result."""
+ invalidate_stats_cache()
yield
- reset_product_event_counts()
-
-
-# ── Handler unit tests ───────────────────────────────────────────────
-
-
-class TestDashboardHandlers:
- async def test_on_product_created_increments_counter(self):
- await on_product_created(ProductCreated(product_id=1, name="Widget"))
- counts = get_product_event_counts()
- assert counts["created"] == 1
- assert counts["updated"] == 0
- assert counts["deleted"] == 0
-
- async def test_on_product_updated_increments_counter(self):
- await on_product_updated(ProductUpdated(product_id=1, name="Widget"))
- counts = get_product_event_counts()
- assert counts["updated"] == 1
- assert counts["created"] == 0
-
- async def test_on_product_deleted_increments_counter(self):
- await on_product_deleted(ProductDeleted(product_id=1))
- counts = get_product_event_counts()
- assert counts["deleted"] == 1
-
- async def test_multiple_events_accumulate(self):
- await on_product_created(ProductCreated(product_id=1, name="A"))
- await on_product_created(ProductCreated(product_id=2, name="B"))
- await on_product_updated(ProductUpdated(product_id=1, name="A2"))
- counts = get_product_event_counts()
- assert counts["created"] == 2
- assert counts["updated"] == 1
- assert counts["deleted"] == 0
-
- async def test_get_product_event_counts_returns_snapshot(self):
- """Returned dict should be a copy, not the internal store."""
- counts = get_product_event_counts()
- counts["created"] = 999
- assert get_product_event_counts()["created"] == 0
-
- async def test_reset_clears_all_counts(self):
- await on_product_created(ProductCreated(product_id=1, name="X"))
- await on_product_deleted(ProductDeleted(product_id=1))
- reset_product_event_counts()
- counts = get_product_event_counts()
- assert counts == {"created": 0, "updated": 0, "deleted": 0}
+ invalidate_stats_cache()
# ── Module registration tests ────────────────────────────────────────
@@ -79,127 +25,79 @@ async def test_module_meta(self):
assert mod.meta.name == "Dashboard"
assert mod.meta.route_prefix == "/api/dashboard"
assert "Products" in mod.meta.depends_on
+ assert "Users" in mod.meta.depends_on
- async def test_register_event_handlers_subscribes_to_all_product_events(self):
- bus = EventBus()
- mod = DashboardModule()
- mod.register_event_handlers(bus)
- await bus.publish(ProductCreated(product_id=1, name="Widget"))
- await bus.publish(ProductUpdated(product_id=1, name="Widget v2"))
- await bus.publish(ProductDeleted(product_id=1))
+# ── Stats function unit tests ────────────────────────────────────────
- counts = get_product_event_counts()
- assert counts == {"created": 1, "updated": 1, "deleted": 1}
+class TestFetchDashboardStats:
+ @pytest.fixture
+ async def stats(self, app):
+ from dashboard.stats import fetch_dashboard_stats
-# ── Stats API endpoint ──────────────────────────────────────────────
+ async with app.state.db.session_factory() as db:
+ return await fetch_dashboard_stats(db, app)
+ async def test_returns_expected_keys(self, stats):
+ assert "total_users" in stats
+ assert "active_users_7d" in stats
+ assert "total_products" in stats
+ assert "module_count" in stats
+ assert "system_info" in stats
-class TestDashboardStatsEndpoint:
- async def test_stats_returns_zero_counts_initially(
- self, authenticated_client: httpx.AsyncClient
- ):
- resp = await authenticated_client.get("/api/dashboard/stats")
- assert resp.status_code == 200
- body = resp.json()
- assert body == {"product_events": {"created": 0, "updated": 0, "deleted": 0}}
+ async def test_total_users_is_non_negative_int(self, stats):
+ assert isinstance(stats["total_users"], int)
+ assert stats["total_users"] >= 0
- async def test_stats_reflects_handler_activity(self, authenticated_client: httpx.AsyncClient):
- await on_product_created(ProductCreated(product_id=1, name="X"))
- await on_product_updated(ProductUpdated(product_id=1, name="X"))
+ async def test_module_count_is_positive(self, stats):
+ assert stats["module_count"] >= 1
- resp = await authenticated_client.get("/api/dashboard/stats")
- assert resp.status_code == 200
- body = resp.json()
- assert body["product_events"]["created"] == 1
- assert body["product_events"]["updated"] == 1
- assert body["product_events"]["deleted"] == 0
+ async def test_system_info_contains_modules_list(self, stats):
+ sys_info = stats["system_info"]
+ assert isinstance(sys_info["modules"], list)
+ assert len(sys_info["modules"]) >= 1
+ assert "name" in sys_info["modules"][0]
+ assert "status" in sys_info["modules"][0]
- async def test_stats_requires_authentication(self, client: httpx.AsyncClient):
- """Unauthenticated requests should be redirected by AuthMiddleware."""
- resp = await client.get("/api/dashboard/stats", follow_redirects=False)
- assert resp.status_code in (302, 401, 403)
+ async def test_system_info_contains_python_version(self, stats):
+ assert "." in stats["system_info"]["python_version"]
+ async def test_system_info_contains_health_checks(self, stats):
+ assert isinstance(stats["system_info"]["health_checks"], list)
-# ── End-to-end: Product API → EventBus → Dashboard handler ──────────
+# ── Stats API endpoint ──────────────────────────────────────────────
-class TestProductEventIntegration:
- async def test_create_product_increments_dashboard_counter(
- self, authenticated_client: httpx.AsyncClient
- ):
- resp = await authenticated_client.post(
- "/api/products/",
- json={"name": "EventTestWidget", "price": "12.34"},
- )
- assert resp.status_code == 201
+_STATS_URL = "/api/dashboard/stats"
- stats = await authenticated_client.get("/api/dashboard/stats")
- assert stats.json()["product_events"]["created"] == 1
- async def test_update_product_increments_dashboard_counter(
- self, authenticated_client: httpx.AsyncClient
- ):
- create = await authenticated_client.post(
- "/api/products/",
- json={"name": "Original", "price": "1.00"},
- )
- product_id = create.json()["id"]
- reset_product_event_counts()
-
- resp = await authenticated_client.put(
- f"/api/products/{product_id}",
- json={"name": "Updated"},
- )
+class TestDashboardStatsEndpoint:
+ async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncClient):
+ resp = await authenticated_client.get(_STATS_URL)
assert resp.status_code == 200
+ body = resp.json()
+ assert "total_users" in body
+ assert "active_users_7d" in body
+ assert "total_products" in body
+ assert "module_count" in body
+ assert "system_info" in body
- stats = await authenticated_client.get("/api/dashboard/stats")
- assert stats.json()["product_events"]["updated"] == 1
-
- async def test_delete_product_increments_dashboard_counter(
+ async def test_stats_total_users_includes_seeded_admin(
self, authenticated_client: httpx.AsyncClient
):
- create = await authenticated_client.post(
- "/api/products/",
- json={"name": "Doomed", "price": "1.00"},
- )
- product_id = create.json()["id"]
- reset_product_event_counts()
-
- resp = await authenticated_client.delete(f"/api/products/{product_id}")
- assert resp.status_code == 204
-
- stats = await authenticated_client.get("/api/dashboard/stats")
- assert stats.json()["product_events"]["deleted"] == 1
-
- async def test_failed_update_does_not_emit_event(self, authenticated_client: httpx.AsyncClient):
- """404s should not publish ProductUpdated — handler logic must be after the lookup."""
- resp = await authenticated_client.put(
- "/api/products/999999",
- json={"name": "ghost"},
- )
- assert resp.status_code == 404
-
- stats = await authenticated_client.get("/api/dashboard/stats")
- assert stats.json()["product_events"]["updated"] == 0
-
- async def test_failed_delete_does_not_emit_event(self, authenticated_client: httpx.AsyncClient):
- resp = await authenticated_client.delete("/api/products/999999")
- assert resp.status_code == 404
-
- stats = await authenticated_client.get("/api/dashboard/stats")
- assert stats.json()["product_events"]["deleted"] == 0
-
- async def test_full_lifecycle_counters(self, authenticated_client: httpx.AsyncClient):
- create = await authenticated_client.post(
- "/api/products/",
- json={"name": "Lifecycle", "price": "1.00"},
- )
- pid = create.json()["id"]
- await authenticated_client.put(f"/api/products/{pid}", json={"name": "L2"})
- await authenticated_client.delete(f"/api/products/{pid}")
-
- stats = await authenticated_client.get("/api/dashboard/stats")
- counts = stats.json()["product_events"]
- assert counts == {"created": 1, "updated": 1, "deleted": 1}
+ resp = await authenticated_client.get(_STATS_URL)
+ body = resp.json()
+ assert body["total_users"] >= 1
+
+ async def test_stats_system_info_has_modules(self, authenticated_client: httpx.AsyncClient):
+ resp = await authenticated_client.get(_STATS_URL)
+ body = resp.json()
+ modules = body["system_info"]["modules"]
+ assert len(modules) >= 1
+ names = [m["name"] for m in modules]
+ assert "Dashboard" in names
+
+ async def test_stats_requires_authentication(self, client: httpx.AsyncClient):
+ resp = await client.get(_STATS_URL, follow_redirects=False)
+ assert resp.status_code in (302, 401, 403)
diff --git a/modules/products/products/models.py b/modules/products/products/models.py
index 68879e5d..a10c434b 100644
--- a/modules/products/products/models.py
+++ b/modules/products/products/models.py
@@ -24,4 +24,4 @@ class Product(Base, AuditMixin, SoftDeleteMixin): # ty: ignore[unsupported-base
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(String(2000), default=None)
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
- is_active: Mapped[bool] = mapped_column(default=True)
+ is_active: Mapped[bool] = mapped_column(default=True, index=True)
diff --git a/modules/products/tests/test_products.py b/modules/products/tests/test_products.py
index b7c2cbda..751494f6 100644
--- a/modules/products/tests/test_products.py
+++ b/modules/products/tests/test_products.py
@@ -11,6 +11,24 @@
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
+# ── Model index guards ───────────────────────────────────────────────
+
+
+def _has_index_on(model, column_name: str) -> bool:
+ """Check that the model's table has an index covering the given column."""
+ table = model.__table__
+ return any(column_name in {c.name for c in idx.columns} for idx in table.indexes)
+
+
+class TestProductModelIndexes:
+ def test_is_active_is_indexed(self):
+ from products.models import Product
+
+ assert _has_index_on(Product, "is_active"), (
+ "Product.is_active must be indexed (used by dashboard product count query)"
+ )
+
+
# ── Schema validation ────────────────────────────────────────────────
diff --git a/modules/users/tests/test_models.py b/modules/users/tests/test_models.py
index 911a11f4..f38f8597 100644
--- a/modules/users/tests/test_models.py
+++ b/modules/users/tests/test_models.py
@@ -23,9 +23,7 @@ def column_names(table) -> set[str]:
return {c.key for c in inspect(table).mapper.column_attrs}
-# ---------------------------------------------------------------------------
-# Model structure tests
-# ---------------------------------------------------------------------------
+# ── Model structure tests ─────────────────────────────────────────────
class TestUserTableShape:
@@ -34,6 +32,13 @@ def test_tablename(self):
assert User.__tablename__ == "users_user"
+ def test_last_login_at_is_indexed(self):
+ from users.models import User
+
+ assert any(
+ "last_login_at" in {c.name for c in i.columns} for i in User.__table__.indexes
+ ), "User.last_login_at must be indexed"
+
def test_required_columns(self):
from users.models import User
diff --git a/modules/users/users/models.py b/modules/users/users/models.py
index 20dda909..ea9f917d 100644
--- a/modules/users/users/models.py
+++ b/modules/users/users/models.py
@@ -33,7 +33,9 @@ class User(SQLAlchemyBaseUserTableUUID, Base, AuditMixin): # ty: ignore[unsuppo
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
tenant_id: Mapped[str | None] = mapped_column(String(50), index=True, default=None)
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
- last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
+ last_login_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), index=True, default=None
+ )
roles: Mapped[list[Role]] = relationship(
secondary="users_user_role",
diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts
index 8394ea68..18d4a801 100644
--- a/packages/i18n/src/generated-resources.ts
+++ b/packages/i18n/src/generated-resources.ts
@@ -7,9 +7,14 @@ export default {
'auth.errors.not_authenticated': '',
'dashboard.home.description': '',
'dashboard.home.description_body': '',
+ 'dashboard.home.stats.active_users': '',
'dashboard.home.stats.modules': '',
'dashboard.home.stats.products': '',
- 'dashboard.home.stats.users': '',
+ 'dashboard.home.stats.total_users': '',
+ 'dashboard.home.system_info.health_checks': '',
+ 'dashboard.home.system_info.modules': '',
+ 'dashboard.home.system_info.python_version': '',
+ 'dashboard.home.system_info_title': '',
'dashboard.home.title': '',
'dashboard.home.welcome_card_title': '',
'dashboard.home.welcome_message': '',
diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts
index 225cd8da..d0ee311e 100644
--- a/packages/i18n/src/keys.generated.ts
+++ b/packages/i18n/src/keys.generated.ts
@@ -13,10 +13,17 @@ export const keys = {
description: 'dashboard.home.description',
description_body: 'dashboard.home.description_body',
stats: {
+ active_users: 'dashboard.home.stats.active_users',
modules: 'dashboard.home.stats.modules',
products: 'dashboard.home.stats.products',
- users: 'dashboard.home.stats.users',
+ total_users: 'dashboard.home.stats.total_users',
},
+ system_info: {
+ health_checks: 'dashboard.home.system_info.health_checks',
+ modules: 'dashboard.home.system_info.modules',
+ python_version: 'dashboard.home.system_info.python_version',
+ },
+ system_info_title: 'dashboard.home.system_info_title',
title: 'dashboard.home.title',
welcome_card_title: 'dashboard.home.welcome_card_title',
welcome_message: 'dashboard.home.welcome_message',