From e3eac93651bdf66353ad6dc8e6db5d7468e7982c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:29:54 +0200 Subject: [PATCH 01/14] refactor(dashboard): remove in-memory event counters, add Users dependency --- modules/dashboard/dashboard/handlers.py | 46 ------------------------- modules/dashboard/dashboard/module.py | 11 +----- modules/dashboard/pyproject.toml | 2 ++ 3 files changed, 3 insertions(+), 56 deletions(-) delete mode 100644 modules/dashboard/dashboard/handlers.py diff --git a/modules/dashboard/dashboard/handlers.py b/modules/dashboard/dashboard/handlers.py deleted file mode 100644 index c4611fbd..00000000 --- a/modules/dashboard/dashboard/handlers.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Event handlers for the Dashboard module. - -Subscribes to product domain events to maintain real-time stats -without direct coupling to the Products module's internals. -""" - -from __future__ import annotations - -import logging - -from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated - -logger = logging.getLogger(__name__) - -_product_event_counts: dict[str, int] = { - "created": 0, - "updated": 0, - "deleted": 0, -} - - -async def on_product_created(event: ProductCreated) -> None: - _product_event_counts["created"] += 1 - logger.info("Dashboard received ProductCreated: %s (id=%d)", event.name, event.product_id) - - -async def on_product_updated(event: ProductUpdated) -> None: - _product_event_counts["updated"] += 1 - logger.info("Dashboard received ProductUpdated: %s (id=%d)", event.name, event.product_id) - - -async def on_product_deleted(event: ProductDeleted) -> None: - _product_event_counts["deleted"] += 1 - logger.info("Dashboard received ProductDeleted: id=%d", event.product_id) - - -def get_product_event_counts() -> dict[str, int]: - """Return a snapshot of product event counts.""" - return dict(_product_event_counts) - - -def reset_product_event_counts() -> None: - """Reset counters — useful for testing.""" - _product_event_counts["created"] = 0 - _product_event_counts["updated"] = 0 - _product_event_counts["deleted"] = 0 diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index 36001fac..17fa7070 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -6,20 +6,16 @@ from pathlib import Path from fastapi import APIRouter -from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated -from simple_module_core.events import EventBus from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module import ModuleBase, ModuleMeta -from dashboard.handlers import on_product_created, on_product_deleted, on_product_updated - class DashboardModule(ModuleBase): meta = ModuleMeta( name="Dashboard", route_prefix="/api/dashboard", view_prefix="/dashboard", - depends_on=["Products"], + depends_on=["Products", "Users"], ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: @@ -40,10 +36,5 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) ) - def register_event_handlers(self, bus: EventBus) -> None: - bus.subscribe(ProductCreated, on_product_created) - bus.subscribe(ProductUpdated, on_product_updated) - bus.subscribe(ProductDeleted, on_product_deleted) - def locale_dirs(self) -> dict[str, Path]: return {"dashboard": Path(str(importlib.resources.files(__package__) / "locales"))} 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 } From 6819601f18263f86aec4f5f8b94e75b5ea75f611 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:32:20 +0200 Subject: [PATCH 02/14] feat(dashboard): add stats-fetching logic and expand /stats endpoint --- modules/dashboard/dashboard/endpoints/api.py | 14 +- modules/dashboard/dashboard/stats.py | 73 ++++++ modules/dashboard/tests/test_dashboard.py | 250 ++++++------------- 3 files changed, 162 insertions(+), 175 deletions(-) create mode 100644 modules/dashboard/dashboard/stats.py diff --git a/modules/dashboard/dashboard/endpoints/api.py b/modules/dashboard/dashboard/endpoints/api.py index e387651b..39256c29 100644 --- a/modules/dashboard/dashboard/endpoints/api.py +++ b/modules/dashboard/dashboard/endpoints/api.py @@ -2,16 +2,16 @@ from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession -from dashboard.handlers import get_product_event_counts +from dashboard.stats import fetch_dashboard_stats router = APIRouter() @router.get("/stats") -async def dashboard_stats() -> dict: - """Return dashboard statistics including product event counts.""" - return { - "product_events": get_product_event_counts(), - } +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + """Return dashboard statistics including user counts and system info.""" + return await fetch_dashboard_stats(db, request.app) diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py new file mode 100644 index 00000000..0a82b7c8 --- /dev/null +++ b/modules/dashboard/dashboard/stats.py @@ -0,0 +1,73 @@ +"""Dashboard statistics queries.""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime, timedelta + +from fastapi import FastAPI +from products.models import Product +from simple_module_core.discovery import discover_modules +from simple_module_core.health import HealthStatus +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from users.models import User + + +async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: + """Gather all dashboard statistics in a single call.""" + total_users = await _count_users(db) + active_users_7d = await _count_active_users(db, days=7) + total_products = await _count_products(db) + module_count, modules_list = _get_module_info() + health_checks = await _run_health_checks(app) + + return { + "total_users": total_users, + "active_users_7d": active_users_7d, + "total_products": total_products, + "module_count": module_count, + "system_info": { + "modules": modules_list, + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "health_checks": health_checks, + }, + } + + +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() -> tuple[int, list[dict[str, str]]]: + modules = discover_modules() + modules_list = [{"name": m.meta.name, "status": "loaded"} for m in modules] + return len(modules), modules_list + + +async def _run_health_checks(app: FastAPI) -> list[dict[str, str]]: + registry = app.state.health_registry + results = [] + for check in registry.all_checks: + try: + result = await check.check() + results.append({"name": check.name, "status": result.status.value}) + except Exception: + results.append({"name": check.name, "status": HealthStatus.UNHEALTHY.value}) + return results diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index 45e05f0f..9ef0e3e0 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -1,73 +1,10 @@ -"""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 - - -@pytest.fixture(autouse=True) -def _reset_counts(): - """Ensure every test starts with zeroed product event counters.""" - reset_product_event_counts() - 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} # ── Module registration tests ──────────────────────────────────────── @@ -79,127 +16,104 @@ 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: + async def test_returns_expected_keys(self, app): + from dashboard.stats import fetch_dashboard_stats -# ── Stats API endpoint ────────────────────────────────────────────── + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + 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_counts_seeded_users(self, app): + from dashboard.stats import fetch_dashboard_stats - 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 with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) - 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 + assert isinstance(stats["total_users"], int) + assert stats["total_users"] >= 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_module_count_is_positive(self, app): + from dashboard.stats import fetch_dashboard_stats + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) -# ── End-to-end: Product API → EventBus → Dashboard handler ────────── + assert stats["module_count"] >= 1 + async def test_system_info_contains_modules_list(self, app): + from dashboard.stats import fetch_dashboard_stats -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 + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) - stats = await authenticated_client.get("/api/dashboard/stats") - assert stats.json()["product_events"]["created"] == 1 + sys_info = stats["system_info"] + assert "modules" in sys_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_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"}, - ) - assert resp.status_code == 200 + async def test_system_info_contains_python_version(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert "python_version" in stats["system_info"] + assert "." in stats["system_info"]["python_version"] - stats = await authenticated_client.get("/api/dashboard/stats") - assert stats.json()["product_events"]["updated"] == 1 + async def test_system_info_contains_health_checks(self, app): + from dashboard.stats import fetch_dashboard_stats - async def test_delete_product_increments_dashboard_counter( + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert "health_checks" in stats["system_info"] + assert isinstance(stats["system_info"]["health_checks"], list) + + +# ── Stats API endpoint ────────────────────────────────────────────── + + +class TestDashboardStatsEndpoint: + async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/dashboard/stats") + 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 + + 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("/api/dashboard/stats") + body = resp.json() + # authenticated_client fixture seeds one admin user + assert body["total_users"] >= 1 + + async def test_stats_system_info_has_modules(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/dashboard/stats") + 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("/api/dashboard/stats", follow_redirects=False) + assert resp.status_code in (302, 401, 403) From 5456b273192217fe3b86d5f1b475682b415abc42 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:33:05 +0200 Subject: [PATCH 03/14] feat(dashboard): pass real stats as Inertia props to Home page --- modules/dashboard/dashboard/endpoints/views.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/dashboard/dashboard/endpoints/views.py b/modules/dashboard/dashboard/endpoints/views.py index d9b465af..90a7052b 100644 --- a/modules/dashboard/dashboard/endpoints/views.py +++ b/modules/dashboard/dashboard/endpoints/views.py @@ -6,20 +6,31 @@ from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Request from inertia import InertiaResponse +from simple_module_db.deps import get_db from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.inertia_deps import InertiaDep +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats router = APIRouter() @router.get("/", response_model=None) -async def dashboard(inertia: InertiaDep, t: TranslatorDep) -> InertiaResponse: +async def dashboard( + request: Request, + inertia: InertiaDep, + t: TranslatorDep, + db: AsyncSession = Depends(get_db), +) -> InertiaResponse: """Authenticated dashboard — requires login (enforced by AuthMiddleware).""" + stats = await fetch_dashboard_stats(db, request.app) return await inertia.render( "Dashboard/Home", { "welcome": t.t("dashboard.home.welcome_message"), + **stats, }, ) From 801f0a9d52eb0d6d7253a781d83e09bf668c2804 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:33:17 +0200 Subject: [PATCH 04/14] feat(dashboard): add i18n keys for new stat cards and system info --- modules/dashboard/dashboard/locales/en.json | 9 ++++++++- modules/dashboard/dashboard/locales/es.json | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/modules/dashboard/dashboard/locales/en.json b/modules/dashboard/dashboard/locales/en.json index 27a6168b..5aa421af 100644 --- a/modules/dashboard/dashboard/locales/en.json +++ b/modules/dashboard/dashboard/locales/en.json @@ -3,10 +3,17 @@ "title": "Dashboard", "description": "Overview of your application", "stats": { + "total_users": "Total Users", + "active_users": "Active Users (7d)", "products": "Products", - "users": "Users", "modules": "Modules" }, + "system_info_title": "System", + "system_info": { + "modules": "Modules", + "python_version": "Python Version", + "health_checks": "Health Checks" + }, "welcome_card_title": "Welcome", "welcome_message": "Welcome to SimpleModule", "description_body": "This is a modular monolith built with FastAPI, Inertia.js, and React. Each module provides its own pages, API endpoints, and database schema." diff --git a/modules/dashboard/dashboard/locales/es.json b/modules/dashboard/dashboard/locales/es.json index 2ceade3c..207975f6 100644 --- a/modules/dashboard/dashboard/locales/es.json +++ b/modules/dashboard/dashboard/locales/es.json @@ -3,10 +3,17 @@ "title": "Panel", "description": "Resumen de tu aplicación", "stats": { + "total_users": "Usuarios Totales", + "active_users": "Usuarios Activos (7d)", "products": "Productos", - "users": "Usuarios", "modules": "Módulos" }, + "system_info_title": "Sistema", + "system_info": { + "modules": "Módulos", + "python_version": "Versión de Python", + "health_checks": "Verificaciones de Salud" + }, "welcome_card_title": "Bienvenido", "welcome_message": "Bienvenido a SimpleModule", "description_body": "Este es un monolito modular construido con FastAPI, Inertia.js y React. Cada módulo proporciona sus propias páginas, endpoints de API y esquema de base de datos." From 2ef7814988b93e202d493c72d6c069fa38b1962d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:34:30 +0200 Subject: [PATCH 05/14] feat(dashboard): rewrite Home page with real stats and system info panel --- modules/dashboard/dashboard/pages/Home.tsx | 125 +++++++++++++++++---- 1 file changed, 106 insertions(+), 19 deletions(-) diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index c57b3321..f65e1661 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -4,20 +4,45 @@ import { PageShell } from '@simple-module/ui/components/PageShell'; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from '@simple-module/ui/components/ui/card'; -import { Separator } from '@simple-module/ui/components/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@simple-module/ui/components/ui/table'; import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; -import { Box, Package, Users } from 'lucide-react'; +import { Activity, Box, Heart, Package, Server, Users } from 'lucide-react'; + +interface SystemModule { + name: string; + status: string; +} + +interface HealthCheck { + name: string; + status: string; +} + +interface SystemInfo { + modules: SystemModule[]; + python_version: string; + health_checks: HealthCheck[]; +} interface Props { welcome: string; + total_users: number; + active_users_7d: number; + total_products: number; + module_count: number; + system_info: SystemInfo; } function Home() { - const { welcome } = usePage<{ props: Props }>().props as unknown as Props; + const props = usePage<{ props: Props }>().props as unknown as Props; const { t } = useT(); return ( @@ -25,37 +50,94 @@ function Home() { title={t(keys.dashboard.home.title)} description={t(keys.dashboard.home.description)} > -
+ {/* Stat Cards */} +
+ } + accent="emerald" + /> + } + accent="amber" + /> } accent="primary" /> - } - accent="emerald" - /> } accent="violet" />
+ {/* System Info */} - - {t(keys.dashboard.home.welcome_card_title)} + + + {t(keys.dashboard.home.system_info_title)} - {welcome} - - -

{t(keys.dashboard.home.description_body)}

+ + {/* Modules */} +
+

+ {t(keys.dashboard.home.system_info.modules)} +

+
+ {props.system_info.modules.map((mod) => ( + + + {mod.name} + + ))} +
+
+ + {/* Python Version + Health Checks */} + + + + + {t(keys.dashboard.home.system_info.python_version)} + + {props.system_info.python_version} + + {props.system_info.health_checks.map((check) => ( + + + + {check.name} + + + + + {check.status} + + + + ))} + +
@@ -89,6 +171,11 @@ 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; From 4ac38a1a4998572dd1f26e7d34b50d425372fa8b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:35:38 +0200 Subject: [PATCH 06/14] fix(dashboard): resolve lint issues in stats and tests --- modules/dashboard/dashboard/stats.py | 5 ++++- modules/dashboard/tests/test_dashboard.py | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py index 0a82b7c8..362fff15 100644 --- a/modules/dashboard/dashboard/stats.py +++ b/modules/dashboard/dashboard/stats.py @@ -29,7 +29,10 @@ async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: "module_count": module_count, "system_info": { "modules": modules_list, - "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "python_version": ( + f"{sys.version_info.major}.{sys.version_info.minor}" + f".{sys.version_info.micro}" + ), "health_checks": health_checks, }, } diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index 9ef0e3e0..d4542659 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -3,10 +3,8 @@ from __future__ import annotations import httpx -import pytest from dashboard.module import DashboardModule - # ── Module registration tests ──────────────────────────────────────── From 58b56ffe4d6d8cf150f30979145ac354bf278c9a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:50:49 +0200 Subject: [PATCH 07/14] refactor(dashboard): simplify stats, fix efficiency and type safety - Cache module list on app.state.modules at startup instead of calling discover_modules() on every request (expensive entry-point rescan) - Parallelize health checks with asyncio.gather instead of sequential loop - Extract health status color map in Home.tsx, type accent as union - Use typed literal unions for HealthCheck.status and SystemModule.status - Deduplicate test setup with shared pytest fixture for stats call --- .../simple_module_hosting/app_builder.py | 1 + modules/dashboard/dashboard/pages/Home.tsx | 26 +++++---- modules/dashboard/dashboard/stats.py | 33 ++++++----- modules/dashboard/tests/test_dashboard.py | 56 ++++++------------- 4 files changed, 51 insertions(+), 65 deletions(-) diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 746f30ac..3d4124d4 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -158,6 +158,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: lifespan=lifespan, ) + app.state.modules = modules app.state.menu_registry = menu_registry app.state.perm_registry = perm_registry app.state.ff_registry = ff_registry diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index f65e1661..3ecdd65d 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -16,14 +16,22 @@ import { import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; import { Activity, Box, Heart, Package, Server, Users } from 'lucide-react'; +type Accent = 'primary' | 'emerald' | 'violet' | 'amber'; + +const HEALTH_STATUS_COLOR: Record = { + healthy: 'bg-emerald-500', + degraded: 'bg-amber-500', + unhealthy: 'bg-red-500', +}; + interface SystemModule { name: string; - status: string; + status: 'loaded'; } interface HealthCheck { name: string; - status: string; + status: 'healthy' | 'degraded' | 'unhealthy'; } interface SystemInfo { @@ -123,13 +131,7 @@ function Home() { {check.status} @@ -153,9 +155,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', @@ -178,7 +180,7 @@ function StatCard({ }, }; - 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 index 362fff15..233eae24 100644 --- a/modules/dashboard/dashboard/stats.py +++ b/modules/dashboard/dashboard/stats.py @@ -2,13 +2,13 @@ from __future__ import annotations +import asyncio import sys from datetime import UTC, datetime, timedelta from fastapi import FastAPI from products.models import Product -from simple_module_core.discovery import discover_modules -from simple_module_core.health import HealthStatus +from simple_module_core.health import HealthCheck, HealthStatus from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from users.models import User @@ -19,14 +19,14 @@ async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: total_users = await _count_users(db) active_users_7d = await _count_active_users(db, days=7) total_products = await _count_products(db) - module_count, modules_list = _get_module_info() + modules_list = _get_module_info(app) health_checks = await _run_health_checks(app) return { "total_users": total_users, "active_users_7d": active_users_7d, "total_products": total_products, - "module_count": module_count, + "module_count": len(modules_list), "system_info": { "modules": modules_list, "python_version": ( @@ -58,19 +58,26 @@ async def _count_products(db: AsyncSession) -> int: return result.scalar_one() -def _get_module_info() -> tuple[int, list[dict[str, str]]]: - modules = discover_modules() - modules_list = [{"name": m.meta.name, "status": "loaded"} for m in modules] - return len(modules), modules_list +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 - results = [] - for check in registry.all_checks: + checks = registry.all_checks + if not checks: + return [] + + async def _run_one(check: HealthCheck) -> dict[str, str]: try: result = await check.check() - results.append({"name": check.name, "status": result.status.value}) + return {"name": check.name, "status": result.status.value} except Exception: - results.append({"name": check.name, "status": HealthStatus.UNHEALTHY.value}) - return results + 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/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index d4542659..f0902f7c 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -3,6 +3,7 @@ from __future__ import annotations import httpx +import pytest from dashboard.module import DashboardModule # ── Module registration tests ──────────────────────────────────────── @@ -21,73 +22,49 @@ async def test_module_meta(self): class TestFetchDashboardStats: - async def test_returns_expected_keys(self, app): + @pytest.fixture + async def stats(self, app): from dashboard.stats import fetch_dashboard_stats async with app.state.db.session_factory() as db: - stats = await fetch_dashboard_stats(db, app) + 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 - async def test_total_users_counts_seeded_users(self, app): - from dashboard.stats import fetch_dashboard_stats - - async with app.state.db.session_factory() as db: - stats = await fetch_dashboard_stats(db, app) - + 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_module_count_is_positive(self, app): - from dashboard.stats import fetch_dashboard_stats - - async with app.state.db.session_factory() as db: - stats = await fetch_dashboard_stats(db, app) - + async def test_module_count_is_positive(self, stats): assert stats["module_count"] >= 1 - async def test_system_info_contains_modules_list(self, app): - from dashboard.stats import fetch_dashboard_stats - - async with app.state.db.session_factory() as db: - stats = await fetch_dashboard_stats(db, app) - + async def test_system_info_contains_modules_list(self, stats): sys_info = stats["system_info"] - assert "modules" in sys_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_system_info_contains_python_version(self, app): - from dashboard.stats import fetch_dashboard_stats - - async with app.state.db.session_factory() as db: - stats = await fetch_dashboard_stats(db, app) - - assert "python_version" in stats["system_info"] + 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, app): - from dashboard.stats import fetch_dashboard_stats - - async with app.state.db.session_factory() as db: - stats = await fetch_dashboard_stats(db, app) - - assert "health_checks" in stats["system_info"] + async def test_system_info_contains_health_checks(self, stats): assert isinstance(stats["system_info"]["health_checks"], list) # ── Stats API endpoint ────────────────────────────────────────────── +_STATS_URL = "/api/dashboard/stats" + class TestDashboardStatsEndpoint: async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncClient): - resp = await authenticated_client.get("/api/dashboard/stats") + resp = await authenticated_client.get(_STATS_URL) assert resp.status_code == 200 body = resp.json() assert "total_users" in body @@ -99,13 +76,12 @@ async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncC async def test_stats_total_users_includes_seeded_admin( self, authenticated_client: httpx.AsyncClient ): - resp = await authenticated_client.get("/api/dashboard/stats") + resp = await authenticated_client.get(_STATS_URL) body = resp.json() - # authenticated_client fixture seeds one admin user assert body["total_users"] >= 1 async def test_stats_system_info_has_modules(self, authenticated_client: httpx.AsyncClient): - resp = await authenticated_client.get("/api/dashboard/stats") + resp = await authenticated_client.get(_STATS_URL) body = resp.json() modules = body["system_info"]["modules"] assert len(modules) >= 1 @@ -113,5 +89,5 @@ async def test_stats_system_info_has_modules(self, authenticated_client: httpx.A assert "Dashboard" in names async def test_stats_requires_authentication(self, client: httpx.AsyncClient): - resp = await client.get("/api/dashboard/stats", follow_redirects=False) + resp = await client.get(_STATS_URL, follow_redirects=False) assert resp.status_code in (302, 401, 403) From 51048eb465a0c14aed62ce6fc1b1379c2d718ed8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:54:25 +0200 Subject: [PATCH 08/14] perf(dashboard): add DB indexes and 30s TTL cache for stats - Add index on User.last_login_at (used by active users 7d query) - Add index on Product.is_active (used by product count query) - Add Alembic migration for both indexes - Cache fetch_dashboard_stats result for 30 seconds to avoid repeated DB round-trips on rapid page loads - Expose invalidate_stats_cache() for test isolation --- ...74312_add_indexes_for_dashboard_queries.py | 32 +++++++++++++++++++ modules/dashboard/dashboard/stats.py | 28 ++++++++++++++-- modules/dashboard/tests/test_dashboard.py | 10 ++++++ modules/products/products/models.py | 2 +- modules/users/users/models.py | 4 ++- 5 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py diff --git a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py new file mode 100644 index 00000000..aa0eda80 --- /dev/null +++ b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py @@ -0,0 +1,32 @@ +"""add indexes for dashboard queries + +Revision ID: a01185374312 +Revises: e3ce9754e6dc +Create Date: 2026-04-15 23:53:08.569085 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'a01185374312' +down_revision: str | None = 'e3ce9754e6dc' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_products_product_is_active'), 'products_product', ['is_active'], unique=False) + op.create_index(op.f('ix_users_user_last_login_at'), 'users_user', ['last_login_at'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_user_last_login_at'), table_name='users_user') + op.drop_index(op.f('ix_products_product_is_active'), table_name='products_product') + # ### end Alembic commands ### diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py index 233eae24..df867e15 100644 --- a/modules/dashboard/dashboard/stats.py +++ b/modules/dashboard/dashboard/stats.py @@ -1,9 +1,10 @@ -"""Dashboard statistics queries.""" +"""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 @@ -13,16 +14,26 @@ from sqlalchemy.ext.asyncio import AsyncSession from users.models import User +_CACHE_TTL_SECONDS = 30 +_cache: dict | None = None +_cache_ts: float = 0.0 + async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: - """Gather all dashboard statistics in a single call.""" + """Gather all dashboard statistics, cached for 30 seconds.""" + global _cache, _cache_ts + + now = time.monotonic() + if _cache is not None and (now - _cache_ts) < _CACHE_TTL_SECONDS: + return _cache + 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) - return { + result = { "total_users": total_users, "active_users_7d": active_users_7d, "total_products": total_products, @@ -37,6 +48,17 @@ async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: }, } + _cache = result + _cache_ts = now + return result + + +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)) diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index f0902f7c..1cf23e55 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -5,6 +5,16 @@ import httpx import pytest from dashboard.module import DashboardModule +from dashboard.stats import invalidate_stats_cache + + +@pytest.fixture(autouse=True) +def _clear_stats_cache(): + """Ensure each test gets fresh stats, not a cached result.""" + invalidate_stats_cache() + yield + invalidate_stats_cache() + # ── Module registration tests ──────────────────────────────────────── 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/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", From d27fcd75b59a3cdbe011ab269b2bcc42e2d54972 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:56:06 +0200 Subject: [PATCH 09/14] test(dashboard): add index guard tests for dashboard query columns --- modules/dashboard/tests/test_dashboard.py | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index 1cf23e55..965deae0 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -16,6 +16,36 @@ def _clear_stats_cache(): invalidate_stats_cache() +# ── Index guard tests ──────────────────────────────────────────────── + + +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 TestDashboardQueryIndexes: + """Ensure indexes required by dashboard queries are present on the models.""" + + def test_user_last_login_at_is_indexed(self): + from users.models import User + + assert _has_index_on(User, "last_login_at"), ( + "User.last_login_at must be indexed for the active-users-7d query" + ) + + def test_product_is_active_is_indexed(self): + from products.models import Product + + assert _has_index_on(Product, "is_active"), ( + "Product.is_active must be indexed for the product count query" + ) + + # ── Module registration tests ──────────────────────────────────────── From e6285f6bad4e6793ebbd91a17d51eb3fc8ab0f47 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:58:17 +0200 Subject: [PATCH 10/14] refactor(tests): move index guards to owning modules Move User.last_login_at index test to users/tests/test_models.py and Product.is_active index test to products/tests/test_products.py so each module owns its own schema guarantees. --- modules/dashboard/tests/test_dashboard.py | 30 ----------------------- modules/products/tests/test_products.py | 17 +++++++++++++ modules/users/tests/test_models.py | 13 ++++++++++ 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index 965deae0..1cf23e55 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -16,36 +16,6 @@ def _clear_stats_cache(): invalidate_stats_cache() -# ── Index guard tests ──────────────────────────────────────────────── - - -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 TestDashboardQueryIndexes: - """Ensure indexes required by dashboard queries are present on the models.""" - - def test_user_last_login_at_is_indexed(self): - from users.models import User - - assert _has_index_on(User, "last_login_at"), ( - "User.last_login_at must be indexed for the active-users-7d query" - ) - - def test_product_is_active_is_indexed(self): - from products.models import Product - - assert _has_index_on(Product, "is_active"), ( - "Product.is_active must be indexed for the product count query" - ) - - # ── Module registration tests ──────────────────────────────────────── diff --git a/modules/products/tests/test_products.py b/modules/products/tests/test_products.py index b7c2cbda..e1fa2ec5 100644 --- a/modules/products/tests/test_products.py +++ b/modules/products/tests/test_products.py @@ -11,6 +11,23 @@ from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession +# ── Model index guards ─────────────────────────────────────────────── + + +class TestProductModelIndexes: + def test_is_active_is_indexed(self): + from products.models import Product + + table = Product.__table__ + indexed = any( + "is_active" in {c.name for c in idx.columns} + for idx in table.indexes + ) + assert indexed, ( + "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..52040bca 100644 --- a/modules/users/tests/test_models.py +++ b/modules/users/tests/test_models.py @@ -28,12 +28,25 @@ def column_names(table) -> set[str]: # --------------------------------------------------------------------------- +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 TestUserTableShape: def test_tablename(self): from users.models import User assert User.__tablename__ == "users_user" + def test_last_login_at_is_indexed(self): + from users.models import User + + assert _has_index_on(User, "last_login_at"), ( + "User.last_login_at must be indexed (used by dashboard active-users query)" + ) + def test_required_columns(self): from users.models import User From b4b7c28564e6a87377106eb1e061b0a6f92dde9a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 16 Apr 2026 00:10:10 +0200 Subject: [PATCH 11/14] fix(dashboard): cache race condition, dead prop, code quality cleanup - Add asyncio.Lock to prevent concurrent coroutines from bypassing the TTL check and duplicating DB queries - Return .copy() of cached dict to prevent caller mutation - Remove unused `welcome` prop from Home.tsx Props interface - Remove unnecessary JSX section comments - Align _has_index_on helper pattern in products tests --- modules/dashboard/dashboard/pages/Home.tsx | 5 -- modules/dashboard/dashboard/stats.py | 69 +++++++++++++--------- modules/products/tests/test_products.py | 13 ++-- 3 files changed, 48 insertions(+), 39 deletions(-) diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index 3ecdd65d..b413126b 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -41,7 +41,6 @@ interface SystemInfo { } interface Props { - welcome: string; total_users: number; active_users_7d: number; total_products: number; @@ -58,7 +57,6 @@ function Home() { title={t(keys.dashboard.home.title)} description={t(keys.dashboard.home.description)} > - {/* Stat Cards */}
- {/* System Info */} @@ -95,7 +92,6 @@ function Home() { - {/* Modules */}

{t(keys.dashboard.home.system_info.modules)} @@ -113,7 +109,6 @@ function Home() {

- {/* Python Version + Health Checks */} diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py index df867e15..921bf4f8 100644 --- a/modules/dashboard/dashboard/stats.py +++ b/modules/dashboard/dashboard/stats.py @@ -17,40 +17,53 @@ _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 - now = time.monotonic() - if _cache is not None and (now - _cache_ts) < _CACHE_TTL_SECONDS: - return _cache - - 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}" - f".{sys.version_info.micro}" - ), - "health_checks": health_checks, - }, - } - - _cache = result - _cache_ts = now - return result + 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}" + f".{sys.version_info.micro}" + ), + "health_checks": health_checks, + }, + } + + _cache = result + _cache_ts = time.monotonic() + return result.copy() def invalidate_stats_cache() -> None: diff --git a/modules/products/tests/test_products.py b/modules/products/tests/test_products.py index e1fa2ec5..751494f6 100644 --- a/modules/products/tests/test_products.py +++ b/modules/products/tests/test_products.py @@ -14,16 +14,17 @@ # ── 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 - table = Product.__table__ - indexed = any( - "is_active" in {c.name for c in idx.columns} - for idx in table.indexes - ) - assert indexed, ( + assert _has_index_on(Product, "is_active"), ( "Product.is_active must be indexed (used by dashboard product count query)" ) From 401a383eeaed23d6be4bc013957ea9220b472f6f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 16 Apr 2026 00:11:33 +0200 Subject: [PATCH 12/14] docs: add dashboard design spec, implementation plan, and generated i18n types --- .../2026-04-15-dashboard-improvements.md | 832 ++++++++++++++++++ ...026-04-15-dashboard-improvements-design.md | 137 +++ packages/i18n/src/generated-resources.ts | 8 +- packages/i18n/src/keys.generated.ts | 191 ++-- 4 files changed, 1075 insertions(+), 93 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-15-dashboard-improvements.md create mode 100644 docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md diff --git a/docs/superpowers/plans/2026-04-15-dashboard-improvements.md b/docs/superpowers/plans/2026-04-15-dashboard-improvements.md new file mode 100644 index 00000000..76bb104b --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-dashboard-improvements.md @@ -0,0 +1,832 @@ +# Dashboard Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the placeholder dashboard with real user/product counts and a system info panel. + +**Architecture:** The dashboard stats endpoint queries the `users_user` and `products_product` tables directly for counts, calls `discover_modules()` for module list, and runs health checks from `app.state.health_registry`. All data is passed as Inertia page props — no separate client-side fetch. The in-memory product event counters are removed. + +**Tech Stack:** FastAPI, SQLAlchemy (async), Inertia.js/React 19, Tailwind CSS 4, lucide-react icons + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `modules/dashboard/dashboard/stats.py` | Create | Stats-fetching logic (DB queries, system info) | +| `modules/dashboard/dashboard/endpoints/api.py` | Modify | Call `fetch_dashboard_stats`, return full stats | +| `modules/dashboard/dashboard/endpoints/views.py` | Modify | Pass stats as Inertia props | +| `modules/dashboard/dashboard/handlers.py` | Delete | Remove in-memory product event counters | +| `modules/dashboard/dashboard/module.py` | Modify | Remove event subscriptions, add Users dependency | +| `modules/dashboard/dashboard/pages/Home.tsx` | Rewrite | Stat cards with real data + system info panel | +| `modules/dashboard/dashboard/locales/en.json` | Modify | Add new i18n keys | +| `modules/dashboard/dashboard/locales/es.json` | Modify | Add new i18n keys | +| `modules/dashboard/pyproject.toml` | Modify | Add `users` dependency | +| `modules/dashboard/tests/test_dashboard.py` | Rewrite | Tests for new stats endpoint and module registration | + +--- + +### Task 1: Add `users` dependency to dashboard module + +**Files:** +- Modify: `modules/dashboard/pyproject.toml` +- Modify: `modules/dashboard/dashboard/module.py` + +- [ ] **Step 1: Add `users` to pyproject.toml dependencies** + +In `modules/dashboard/pyproject.toml`, add `"users"` to both `[project] dependencies` and `[tool.uv.sources]`: + +```toml +[project] +dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", + "products", + "users", +] + +[tool.uv.sources] +simple-module-core = { workspace = true } +simple-module-db = { workspace = true } +simple-module-hosting = { workspace = true } +products = { workspace = true } +users = { workspace = true } +``` + +- [ ] **Step 2: Update module metadata to depend on Users** + +In `modules/dashboard/dashboard/module.py`, change `depends_on`: + +```python +class DashboardModule(ModuleBase): + meta = ModuleMeta( + name="Dashboard", + route_prefix="/api/dashboard", + view_prefix="/dashboard", + depends_on=["Products", "Users"], + ) +``` + +- [ ] **Step 3: Remove event handler registration from module** + +In `modules/dashboard/dashboard/module.py`, remove the `register_event_handlers` method entirely, and remove the imports of `on_product_created`, `on_product_deleted`, `on_product_updated` from `dashboard.handlers`, and the imports of `ProductCreated`, `ProductDeleted`, `ProductUpdated` from `products.contracts.events`, and the import of `EventBus` from `simple_module_core.events`. + +The resulting `module.py`: + +```python +"""Dashboard module definition.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +from fastapi import APIRouter +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta + + +class DashboardModule(ModuleBase): + meta = ModuleMeta( + name="Dashboard", + route_prefix="/api/dashboard", + view_prefix="/dashboard", + depends_on=["Products", "Users"], + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from dashboard.endpoints.api import router as api + from dashboard.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Dashboard", + url="/dashboard", + icon="home", + order=1, + section=MenuSection.SIDEBAR, + ) + ) + + def locale_dirs(self) -> dict[str, Path]: + return {"dashboard": Path(str(importlib.resources.files(__package__) / "locales"))} +``` + +- [ ] **Step 4: Delete handlers.py** + +Delete the file `modules/dashboard/dashboard/handlers.py` entirely — the in-memory counters are no longer used. + +- [ ] **Step 5: Commit** + +```bash +git add modules/dashboard/pyproject.toml modules/dashboard/dashboard/module.py +git rm modules/dashboard/dashboard/handlers.py +git commit -m "refactor(dashboard): remove in-memory event counters, add Users dependency" +``` + +--- + +### Task 2: Create stats-fetching backend logic + +**Files:** +- Create: `modules/dashboard/dashboard/stats.py` +- Test: `modules/dashboard/tests/test_dashboard.py` + +- [ ] **Step 1: Write the failing test for `fetch_dashboard_stats`** + +Replace the contents of `modules/dashboard/tests/test_dashboard.py` with: + +```python +"""Tests for the Dashboard module: stats endpoint and module registration.""" + +from __future__ import annotations + +import httpx +import pytest +from dashboard.module import DashboardModule + + +# ── Module registration tests ──────────────────────────────────────── + + +class TestDashboardModuleRegistration: + async def test_module_meta(self): + mod = DashboardModule() + 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 + + +# ── Stats function unit tests ──────────────────────────────────────── + + +class TestFetchDashboardStats: + async def test_returns_expected_keys(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + 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 + + async def test_total_users_counts_seeded_users(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + # The app fixture seeds at least one admin user via authenticated_client deps, + # but fetch_dashboard_stats only counts what's in the DB at call time. + assert isinstance(stats["total_users"], int) + assert stats["total_users"] >= 0 + + async def test_module_count_is_positive(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert stats["module_count"] >= 1 + + async def test_system_info_contains_modules_list(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + sys_info = stats["system_info"] + assert "modules" in sys_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_system_info_contains_python_version(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert "python_version" in stats["system_info"] + assert "." in stats["system_info"]["python_version"] + + async def test_system_info_contains_health_checks(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert "health_checks" in stats["system_info"] + assert isinstance(stats["system_info"]["health_checks"], list) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'dashboard.stats'` + +- [ ] **Step 3: Implement `fetch_dashboard_stats`** + +Create `modules/dashboard/dashboard/stats.py`: + +```python +"""Dashboard statistics queries.""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime, timedelta + +from fastapi import FastAPI +from products.models import Product +from simple_module_core.discovery import discover_modules +from simple_module_core.health import HealthStatus +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from users.models import User + + +async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: + """Gather all dashboard statistics in a single call.""" + total_users = await _count_users(db) + active_users_7d = await _count_active_users(db, days=7) + total_products = await _count_products(db) + module_count, modules_list = _get_module_info() + health_checks = await _run_health_checks(app) + + return { + "total_users": total_users, + "active_users_7d": active_users_7d, + "total_products": total_products, + "module_count": module_count, + "system_info": { + "modules": modules_list, + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "health_checks": health_checks, + }, + } + + +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() -> tuple[int, list[dict[str, str]]]: + modules = discover_modules() + modules_list = [{"name": m.meta.name, "status": "loaded"} for m in modules] + return len(modules), modules_list + + +async def _run_health_checks(app: FastAPI) -> list[dict[str, str]]: + registry = app.state.health_registry + results = [] + for check in registry.all_checks: + try: + result = await check.check() + results.append({"name": check.name, "status": result.status.value}) + except Exception: + results.append({"name": check.name, "status": HealthStatus.UNHEALTHY.value}) + return results +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: All `TestFetchDashboardStats` tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/dashboard/dashboard/stats.py modules/dashboard/tests/test_dashboard.py +git commit -m "feat(dashboard): add stats-fetching logic with real DB queries" +``` + +--- + +### Task 3: Update stats API endpoint + +**Files:** +- Modify: `modules/dashboard/dashboard/endpoints/api.py` +- Test: `modules/dashboard/tests/test_dashboard.py` + +- [ ] **Step 1: Add API endpoint tests** + +Append to `modules/dashboard/tests/test_dashboard.py`: + +```python +# ── Stats API endpoint ────────────────────────────────────────────── + + +class TestDashboardStatsEndpoint: + async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/dashboard/stats") + 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 + + async def test_stats_total_users_includes_seeded_admin( + self, authenticated_client: httpx.AsyncClient + ): + resp = await authenticated_client.get("/api/dashboard/stats") + body = resp.json() + # authenticated_client fixture seeds one admin user + assert body["total_users"] >= 1 + + async def test_stats_system_info_has_modules(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/dashboard/stats") + 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("/api/dashboard/stats", follow_redirects=False) + assert resp.status_code in (302, 401, 403) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py::TestDashboardStatsEndpoint -v` +Expected: FAIL — old endpoint returns `{"product_events": ...}` shape + +- [ ] **Step 3: Update the stats endpoint** + +Replace `modules/dashboard/dashboard/endpoints/api.py` with: + +```python +"""REST API endpoints for the Dashboard module.""" + +from __future__ import annotations + +from fastapi import APIRouter, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats + +router = APIRouter() + + +@router.get("/stats") +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + """Return dashboard statistics including user counts and system info.""" + return await fetch_dashboard_stats(db, request.app) +``` + +Note: add `from fastapi import APIRouter, Depends, Request` (include `Depends`). + +Corrected file: + +```python +"""REST API endpoints for the Dashboard module.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats + +router = APIRouter() + + +@router.get("/stats") +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + """Return dashboard statistics including user counts and system info.""" + return await fetch_dashboard_stats(db, request.app) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/dashboard/dashboard/endpoints/api.py modules/dashboard/tests/test_dashboard.py +git commit -m "feat(dashboard): expand /stats endpoint with real counts and system info" +``` + +--- + +### Task 4: Update Inertia view endpoint to pass stats as props + +**Files:** +- Modify: `modules/dashboard/dashboard/endpoints/views.py` + +- [ ] **Step 1: Update the view endpoint** + +Replace `modules/dashboard/dashboard/endpoints/views.py` with: + +```python +"""Inertia view endpoints for the Dashboard. + +Mounted under ``/dashboard`` via :attr:`DashboardModule.meta.view_prefix`. +The public landing page at ``/`` is owned by the host, not this module. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from inertia import InertiaResponse +from simple_module_db.deps import get_db +from simple_module_hosting.i18n_deps import TranslatorDep +from simple_module_hosting.inertia_deps import InertiaDep +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats + +router = APIRouter() + + +@router.get("/", response_model=None) +async def dashboard( + request: Request, + inertia: InertiaDep, + t: TranslatorDep, + db: AsyncSession = Depends(get_db), +) -> InertiaResponse: + """Authenticated dashboard — requires login (enforced by AuthMiddleware).""" + stats = await fetch_dashboard_stats(db, request.app) + return await inertia.render( + "Dashboard/Home", + { + "welcome": t.t("dashboard.home.welcome_message"), + **stats, + }, + ) +``` + +- [ ] **Step 2: Run full test suite to verify nothing breaks** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: All tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add modules/dashboard/dashboard/endpoints/views.py +git commit -m "feat(dashboard): pass real stats as Inertia props to Home page" +``` + +--- + +### Task 5: Update i18n locale files + +**Files:** +- Modify: `modules/dashboard/dashboard/locales/en.json` +- Modify: `modules/dashboard/dashboard/locales/es.json` + +- [ ] **Step 1: Update English locale** + +Replace `modules/dashboard/dashboard/locales/en.json` with: + +```json +{ + "home": { + "title": "Dashboard", + "description": "Overview of your application", + "stats": { + "total_users": "Total Users", + "active_users": "Active Users (7d)", + "products": "Products", + "modules": "Modules" + }, + "system_info_title": "System", + "system_info": { + "modules": "Modules", + "python_version": "Python Version", + "health_checks": "Health Checks" + }, + "welcome_card_title": "Welcome", + "welcome_message": "Welcome to SimpleModule", + "description_body": "This is a modular monolith built with FastAPI, Inertia.js, and React. Each module provides its own pages, API endpoints, and database schema." + } +} +``` + +- [ ] **Step 2: Update Spanish locale** + +Replace `modules/dashboard/dashboard/locales/es.json` with: + +```json +{ + "home": { + "title": "Panel", + "description": "Resumen de tu aplicación", + "stats": { + "total_users": "Usuarios Totales", + "active_users": "Usuarios Activos (7d)", + "products": "Productos", + "modules": "Módulos" + }, + "system_info_title": "Sistema", + "system_info": { + "modules": "Módulos", + "python_version": "Versión de Python", + "health_checks": "Verificaciones de Salud" + }, + "welcome_card_title": "Bienvenido", + "welcome_message": "Bienvenido a SimpleModule", + "description_body": "Este es un monolito modular construido con FastAPI, Inertia.js y React. Cada módulo proporciona sus propias páginas, endpoints de API y esquema de base de datos." + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/dashboard/dashboard/locales/en.json modules/dashboard/dashboard/locales/es.json +git commit -m "feat(dashboard): add i18n keys for new stat cards and system info" +``` + +--- + +### Task 6: Rewrite Home.tsx frontend + +**Files:** +- Rewrite: `modules/dashboard/dashboard/pages/Home.tsx` + +- [ ] **Step 1: Rewrite Home.tsx with real stats and system info panel** + +Replace `modules/dashboard/dashboard/pages/Home.tsx` with: + +```tsx +import { usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@simple-module/ui/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@simple-module/ui/components/ui/table'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { Activity, Box, Heart, Package, Server, Users } from 'lucide-react'; + +interface SystemModule { + name: string; + status: string; +} + +interface HealthCheck { + name: string; + status: string; +} + +interface SystemInfo { + modules: SystemModule[]; + python_version: string; + health_checks: HealthCheck[]; +} + +interface Props { + welcome: string; + total_users: number; + active_users_7d: number; + total_products: number; + module_count: number; + system_info: SystemInfo; +} + +function Home() { + const props = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + + return ( + + {/* Stat Cards */} +
+ } + accent="emerald" + /> + } + accent="amber" + /> + } + accent="primary" + /> + } + accent="violet" + /> +
+ + {/* System Info */} + + + + + {t(keys.dashboard.home.system_info_title)} + + + + {/* Modules */} +
+

+ {t(keys.dashboard.home.system_info.modules)} +

+
+ {props.system_info.modules.map((mod) => ( + + + {mod.name} + + ))} +
+
+ + {/* Python Version + Health Checks */} +
+ + + + {t(keys.dashboard.home.system_info.python_version)} + + {props.system_info.python_version} + + {props.system_info.health_checks.map((check) => ( + + + + {check.name} + + + + + {check.status} + + + + ))} + +
+ + + + ); +} + +function StatCard({ + title, + value, + icon, + accent, +}: { + title: string; + value: string; + icon: React.ReactNode; + accent: string; +}) { + const styles: Record = { + primary: { + card: 'border-primary-200 bg-gradient-to-br from-primary-50 to-card', + icon: 'text-primary-500 bg-primary-100', + value: 'text-primary-900', + }, + emerald: { + card: 'border-emerald-border bg-gradient-to-br from-emerald-bg to-card', + icon: 'text-emerald-icon-fg bg-emerald-icon-bg', + value: 'text-emerald-value', + }, + violet: { + card: 'border-violet-border bg-gradient-to-br from-violet-bg to-card', + 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; + + return ( + + +
+ {title} +
+ {icon} +
+
+

{value}

+
+
+ ); +} + +Home.layout = (page: React.ReactNode) => {page}; +export default Home; +``` + +- [ ] **Step 2: Verify the frontend builds** + +Run: `npm run build --workspace=host` (or the project's build command) +Expected: Build succeeds with no TypeScript errors + +- [ ] **Step 3: Commit** + +```bash +git add modules/dashboard/dashboard/pages/Home.tsx +git commit -m "feat(dashboard): rewrite Home page with real stats and system info panel" +``` + +--- + +### Task 7: Run full test suite and fix any issues + +- [ ] **Step 1: Run all dashboard tests** + +Run: `python -m pytest modules/dashboard/tests/ -v` +Expected: All tests PASS + +- [ ] **Step 2: Run integration tests that touch dashboard** + +Run: `python -m pytest tests/integration/ -v -k "dashboard or product"` +Expected: All tests PASS (product integration tests that checked old `product_events` key will fail — see next step) + +- [ ] **Step 3: Fix integration tests if needed** + +The old integration tests in `modules/dashboard/tests/test_dashboard.py` that referenced `product_events` are already replaced in Task 2. But `tests/integration/test_products_journey.py` may reference the old stats shape. Check and update if needed. + +Run: `python -m pytest tests/integration/test_products_journey.py -v` + +- [ ] **Step 4: Run linter** + +Run: `ruff check modules/dashboard/` +Expected: No errors + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "fix(dashboard): resolve test and lint issues from dashboard rewrite" +``` diff --git a/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md b/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md new file mode 100644 index 00000000..3199391d --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md @@ -0,0 +1,137 @@ +# Dashboard Improvements: Real Stats + System Info + +**Date:** 2026-04-15 +**Status:** Draft +**Module:** `modules/dashboard` + +## Goal + +Replace the current placeholder dashboard with a data-rich overview showing real user/product counts and system information. Activity logging and audit trails are out of scope — a separate audit log module will handle that. + +## Current State + +The dashboard (`modules/dashboard/dashboard/pages/Home.tsx`) renders: + +- 3 stat cards with hardcoded/placeholder values (Products: "-", Users: "-", Modules: "3") +- A static welcome card +- In-memory product event counters (not persisted, lost on restart) + +The backend view endpoint passes only a `welcome` string to the frontend via Inertia. + +## Design + +### Section 1: Stat Cards (replace placeholders with real data) + +Four stat cards in a responsive grid: + +| Card | Source | Query | +|------|--------|-------| +| **Total Users** | `users_user` table | `SELECT COUNT(*) FROM users_user` | +| **Active Users** (7d) | `users_user.last_login_at` | `WHERE last_login_at >= NOW() - INTERVAL '7 days'` | +| **Products** | `products_product` table | `SELECT COUNT(*) FROM products_product` | +| **Modules** | `discover_modules()` | `len(discover_modules())` | + +The stat card grid changes from 3 to 4 columns: `grid-cols-1 sm:grid-cols-2 md:grid-cols-4`. + +Active Users gets a new accent color (amber) to differentiate from Total Users (emerald stays for total). + +### Section 2: System Info Panel + +A card displayed below the stat cards showing: + +- **Loaded modules** — name and status badge (green dot) for each discovered module +- **Python version** — from `sys.version` +- **Health checks** — results from `app.state.health_registry` with green/yellow/red status indicators + +### Section 3: Dashboard Stats API (updated) + +Expand `GET /api/dashboard/stats` to return all data the frontend needs: + +```python +@router.get("/stats") +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + return { + "total_users": , + "active_users_7d": = 7 days ago>, + "total_products": , + "module_count": , + "system_info": { + "modules": [ + {"name": "Auth", "status": "loaded"}, + {"name": "Users", "status": "loaded"}, + {"name": "Products", "status": "loaded"}, + {"name": "Dashboard", "status": "loaded"}, + ], + "python_version": "3.12.x", + "health_checks": [ + {"name": "database", "status": "healthy"}, + ], + }, + } +``` + +Cross-module DB queries: The stats endpoint queries `users_user` and `products_product` tables directly (same database). This is a read-only cross-module query, acceptable for an aggregation endpoint. + +### Section 4: Dashboard View Endpoint (updated) + +The Inertia view endpoint (`GET /dashboard`) passes all stats as page props: + +```python +@router.get("/") +async def dashboard(inertia: InertiaDep, t: TranslatorDep, db: ...) -> InertiaResponse: + stats = await fetch_dashboard_stats(db, request) + return await inertia.render("Dashboard/Home", { + "welcome": t.t("dashboard.home.welcome_message"), + **stats, + }) +``` + +### Section 5: Frontend — Home.tsx (rewritten) + +The page receives all stats as Inertia page props and renders: + +1. **Stat Cards Row** — 4-column responsive grid with real values +2. **System Info Panel** — card listing modules, Python version, health checks + +**System Info Panel:** +- Card listing loaded modules with status badges (green dot for loaded) +- Python version display +- Health check results with status indicator (green/yellow/red dot) +- Wrapped in a `Card` with title "System" + +### Section 6: i18n Updates + +Add new translation keys to `modules/dashboard/dashboard/locales/en.json` (and `es.json`): + +- `stats.active_users`: "Active Users (7d)" +- `system_info_title`: "System" +- `system_info.modules`: "Modules" +- `system_info.python_version`: "Python Version" +- `system_info.health_checks`: "Health Checks" + +### Section 7: Cleanup + +Remove the in-memory product event counters from `handlers.py` since they serve no purpose without the activity log. The dashboard module still depends on Products (for the product count query) but no longer subscribes to product events. + +## Files to Modify + +| File | Changes | +|------|---------| +| `modules/dashboard/dashboard/handlers.py` | Remove in-memory counters (file may become empty/deleted) | +| `modules/dashboard/dashboard/module.py` | Remove event handler subscriptions, add `depends_on=["Users"]` | +| `modules/dashboard/dashboard/endpoints/api.py` | Expand `/stats` with real counts + system info | +| `modules/dashboard/dashboard/endpoints/views.py` | Pass full stats as Inertia props | +| `modules/dashboard/dashboard/pages/Home.tsx` | Rewrite with real stat cards + system info panel | +| `modules/dashboard/dashboard/locales/en.json` | Add new translation keys | +| `modules/dashboard/dashboard/locales/es.json` | Add new translation keys | +| `modules/dashboard/pyproject.toml` | Add dependency on `users` | +| `modules/dashboard/tests/test_dashboard.py` | Rewrite tests for new stats endpoint | + +## Out of Scope + +- Activity/audit logging (separate module) +- Event handler persistence +- Recent activity feed +- Activity charts +- Real-time updates +- Dashboard customization diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 8394ea68..f1b8b0b4 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,15 +1,21 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. + export default { translation: { 'auth.errors.missing_permission': '', '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..a1fb7dec 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,138 +4,145 @@ export const keys = { auth: { errors: { - missing_permission: 'auth.errors.missing_permission', - not_authenticated: 'auth.errors.not_authenticated', + missing_permission: "auth.errors.missing_permission", + not_authenticated: "auth.errors.not_authenticated", }, }, dashboard: { home: { - description: 'dashboard.home.description', - description_body: 'dashboard.home.description_body', + description: "dashboard.home.description", + description_body: "dashboard.home.description_body", stats: { - modules: 'dashboard.home.stats.modules', - products: 'dashboard.home.stats.products', - users: 'dashboard.home.stats.users', + active_users: "dashboard.home.stats.active_users", + modules: "dashboard.home.stats.modules", + products: "dashboard.home.stats.products", + total_users: "dashboard.home.stats.total_users", }, - title: 'dashboard.home.title', - welcome_card_title: 'dashboard.home.welcome_card_title', - welcome_message: 'dashboard.home.welcome_message', + 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", }, }, host: { error: { - forbidden_description: 'host.error.forbidden_description', - forbidden_title: 'host.error.forbidden_title', - generic_description: 'host.error.generic_description', - generic_title: 'host.error.generic_title', - go_back: 'host.error.go_back', - go_home: 'host.error.go_home', - not_found_description: 'host.error.not_found_description', - not_found_title: 'host.error.not_found_title', - server_error_description: 'host.error.server_error_description', - server_error_title: 'host.error.server_error_title', + forbidden_description: "host.error.forbidden_description", + forbidden_title: "host.error.forbidden_title", + generic_description: "host.error.generic_description", + generic_title: "host.error.generic_title", + go_back: "host.error.go_back", + go_home: "host.error.go_home", + not_found_description: "host.error.not_found_description", + not_found_title: "host.error.not_found_title", + server_error_description: "host.error.server_error_description", + server_error_title: "host.error.server_error_title", }, landing: { - badge: 'host.landing.badge', - cta_dashboard: 'host.landing.cta_dashboard', - cta_docs: 'host.landing.cta_docs', - cta_get_started: 'host.landing.cta_get_started', + badge: "host.landing.badge", + cta_dashboard: "host.landing.cta_dashboard", + cta_docs: "host.landing.cta_docs", + cta_get_started: "host.landing.cta_get_started", features: { - auth_description: 'host.landing.features.auth_description', - auth_title: 'host.landing.features.auth_title', - devtools_description: 'host.landing.features.devtools_description', - devtools_title: 'host.landing.features.devtools_title', - diagnostics_description: 'host.landing.features.diagnostics_description', - diagnostics_title: 'host.landing.features.diagnostics_title', - inertia_description: 'host.landing.features.inertia_description', - inertia_title: 'host.landing.features.inertia_title', - module_system_description: 'host.landing.features.module_system_description', - module_system_title: 'host.landing.features.module_system_title', - schema_description: 'host.landing.features.schema_description', - schema_title: 'host.landing.features.schema_title', + auth_description: "host.landing.features.auth_description", + auth_title: "host.landing.features.auth_title", + devtools_description: "host.landing.features.devtools_description", + devtools_title: "host.landing.features.devtools_title", + diagnostics_description: "host.landing.features.diagnostics_description", + diagnostics_title: "host.landing.features.diagnostics_title", + inertia_description: "host.landing.features.inertia_description", + inertia_title: "host.landing.features.inertia_title", + module_system_description: "host.landing.features.module_system_description", + module_system_title: "host.landing.features.module_system_title", + schema_description: "host.landing.features.schema_description", + schema_title: "host.landing.features.schema_title", }, - hero_subtitle: 'host.landing.hero_subtitle', - hero_title_line1: 'host.landing.hero_title_line1', - hero_title_line2: 'host.landing.hero_title_line2', + hero_subtitle: "host.landing.hero_subtitle", + hero_title_line1: "host.landing.hero_title_line1", + hero_title_line2: "host.landing.hero_title_line2", }, }, products: { browse: { - count: 'products.browse.count', - count_one: 'products.browse.count_one', - count_other: 'products.browse.count_other', - create_button: 'products.browse.create_button', - description: 'products.browse.description', - empty_description: 'products.browse.empty_description', - empty_title: 'products.browse.empty_title', - new_button: 'products.browse.new_button', - no_match: 'products.browse.no_match', - search_placeholder: 'products.browse.search_placeholder', - title: 'products.browse.title', + count: "products.browse.count", + count_one: "products.browse.count_one", + count_other: "products.browse.count_other", + create_button: "products.browse.create_button", + description: "products.browse.description", + empty_description: "products.browse.empty_description", + empty_title: "products.browse.empty_title", + new_button: "products.browse.new_button", + no_match: "products.browse.no_match", + search_placeholder: "products.browse.search_placeholder", + title: "products.browse.title", }, create: { - description: 'products.create.description', - submit_button: 'products.create.submit_button', - submitting_button: 'products.create.submitting_button', - title: 'products.create.title', + description: "products.create.description", + submit_button: "products.create.submit_button", + submitting_button: "products.create.submitting_button", + title: "products.create.title", }, delete_dialog: { - cancel: 'products.delete_dialog.cancel', - confirm: 'products.delete_dialog.confirm', - description: 'products.delete_dialog.description', - title: 'products.delete_dialog.title', + cancel: "products.delete_dialog.cancel", + confirm: "products.delete_dialog.confirm", + description: "products.delete_dialog.description", + title: "products.delete_dialog.title", }, edit: { - back_button: 'products.edit.back_button', - description: 'products.edit.description', - submit_button: 'products.edit.submit_button', - submitting_button: 'products.edit.submitting_button', - title: 'products.edit.title', + back_button: "products.edit.back_button", + description: "products.edit.description", + submit_button: "products.edit.submit_button", + submitting_button: "products.edit.submitting_button", + title: "products.edit.title", }, errors: { - not_found: 'products.errors.not_found', + not_found: "products.errors.not_found", }, form: { - active_label: 'products.form.active_label', - cancel_button: 'products.form.cancel_button', - description_label: 'products.form.description_label', - description_placeholder: 'products.form.description_placeholder', - name_label: 'products.form.name_label', - name_placeholder: 'products.form.name_placeholder', - price_label: 'products.form.price_label', - price_placeholder: 'products.form.price_placeholder', + active_label: "products.form.active_label", + cancel_button: "products.form.cancel_button", + description_label: "products.form.description_label", + description_placeholder: "products.form.description_placeholder", + name_label: "products.form.name_label", + name_placeholder: "products.form.name_placeholder", + price_label: "products.form.price_label", + price_placeholder: "products.form.price_placeholder", }, table: { - actions: 'products.table.actions', - active: 'products.table.active', - description: 'products.table.description', - inactive: 'products.table.inactive', - name: 'products.table.name', - price: 'products.table.price', - status: 'products.table.status', + actions: "products.table.actions", + active: "products.table.active", + description: "products.table.description", + inactive: "products.table.inactive", + name: "products.table.name", + price: "products.table.price", + status: "products.table.status", }, toasts: { - created: 'products.toasts.created', - delete_failed: 'products.toasts.delete_failed', - deleted: 'products.toasts.deleted', - updated: 'products.toasts.updated', + created: "products.toasts.created", + delete_failed: "products.toasts.delete_failed", + deleted: "products.toasts.deleted", + updated: "products.toasts.updated", }, validation: { - name_required: 'products.validation.name_required', - name_too_long: 'products.validation.name_too_long', - price_positive: 'products.validation.price_positive', - price_required: 'products.validation.price_required', + name_required: "products.validation.name_required", + name_too_long: "products.validation.name_too_long", + price_positive: "products.validation.price_positive", + price_required: "products.validation.price_required", }, }, ui: { errors: { - generic_description: 'ui.errors.generic_description', - generic_title: 'ui.errors.generic_title', - go_home_button: 'ui.errors.go_home_button', - reload_button: 'ui.errors.reload_button', + generic_description: "ui.errors.generic_description", + generic_title: "ui.errors.generic_title", + go_home_button: "ui.errors.go_home_button", + reload_button: "ui.errors.reload_button", }, switcher: { - label: 'ui.switcher.label', + label: "ui.switcher.label", }, }, } as const; From 8fadab795c25ddf406e2e6617fdba58b0e9777a2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 16 Apr 2026 00:15:08 +0200 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20CI=20failures=20=E2=80=94=20format?= =?UTF-8?q?ting,=20file=20size=20cap,=20JS=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruff format on stats.py and migration file - biome format on generated i18n files (double→single quotes) - Trim test_models.py to 298 lines (was 307, cap is 300) - Remove dead welcome prop and section comments from Home.tsx --- ...5374312_add_indexes_for_dashboard_queries.py | 16 ++++++++++------ modules/dashboard/dashboard/stats.py | 3 +-- modules/users/tests/test_models.py | 17 ++++------------- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py index aa0eda80..76eafa10 100644 --- a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py +++ b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py @@ -12,21 +12,25 @@ # revision identifiers, used by Alembic. -revision: str = 'a01185374312' -down_revision: str | None = 'e3ce9754e6dc' +revision: str = "a01185374312" +down_revision: str | None = "e3ce9754e6dc" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_index(op.f('ix_products_product_is_active'), 'products_product', ['is_active'], unique=False) - op.create_index(op.f('ix_users_user_last_login_at'), 'users_user', ['last_login_at'], unique=False) + op.create_index( + op.f("ix_products_product_is_active"), "products_product", ["is_active"], unique=False + ) + op.create_index( + op.f("ix_users_user_last_login_at"), "users_user", ["last_login_at"], unique=False + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_user_last_login_at'), table_name='users_user') - op.drop_index(op.f('ix_products_product_is_active'), table_name='products_product') + op.drop_index(op.f("ix_users_user_last_login_at"), table_name="users_user") + op.drop_index(op.f("ix_products_product_is_active"), table_name="products_product") # ### end Alembic commands ### diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py index 921bf4f8..7ca42454 100644 --- a/modules/dashboard/dashboard/stats.py +++ b/modules/dashboard/dashboard/stats.py @@ -54,8 +54,7 @@ async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: "system_info": { "modules": modules_list, "python_version": ( - f"{sys.version_info.major}.{sys.version_info.minor}" - f".{sys.version_info.micro}" + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" ), "health_checks": health_checks, }, diff --git a/modules/users/tests/test_models.py b/modules/users/tests/test_models.py index 52040bca..177d9adf 100644 --- a/modules/users/tests/test_models.py +++ b/modules/users/tests/test_models.py @@ -23,16 +23,7 @@ def column_names(table) -> set[str]: return {c.key for c in inspect(table).mapper.column_attrs} -# --------------------------------------------------------------------------- -# Model structure tests -# --------------------------------------------------------------------------- - - -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) - +# ── Model structure tests ───────────────────────────────────────────── class TestUserTableShape: def test_tablename(self): @@ -43,9 +34,9 @@ def test_tablename(self): def test_last_login_at_is_indexed(self): from users.models import User - assert _has_index_on(User, "last_login_at"), ( - "User.last_login_at must be indexed (used by dashboard active-users query)" - ) + 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 From 1b495ff482d5040aa89bd58aaef73fad62690557 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 16 Apr 2026 00:18:51 +0200 Subject: [PATCH 14/14] fix: pass all CI checks (format, lint, file size) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - biome format Home.tsx (collapse multi-line imports) - biome check i18n generated files (double→single quotes, organize imports) - ruff fix migration (remove unused sqlalchemy import, sort imports) - ruff format test_models.py --- ...74312_add_indexes_for_dashboard_queries.py | 2 - modules/dashboard/dashboard/pages/Home.tsx | 14 +- modules/users/tests/test_models.py | 1 + packages/i18n/src/generated-resources.ts | 1 - packages/i18n/src/keys.generated.ts | 194 +++++++++--------- 5 files changed, 100 insertions(+), 112 deletions(-) diff --git a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py index 76eafa10..24bc54c9 100644 --- a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py +++ b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py @@ -7,10 +7,8 @@ from collections.abc import Sequence -import sqlalchemy as sa from alembic import op - # revision identifiers, used by Alembic. revision: str = "a01185374312" down_revision: str | None = "e3ce9754e6dc" diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index b413126b..acf64e2d 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -1,18 +1,8 @@ import { usePage } from '@inertiajs/react'; import { keys, useT } from '@simple-module/i18n'; import { PageShell } from '@simple-module/ui/components/PageShell'; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from '@simple-module/ui/components/ui/card'; -import { - Table, - TableBody, - TableCell, - TableRow, -} from '@simple-module/ui/components/ui/table'; +import { Card, CardContent, CardHeader, CardTitle } from '@simple-module/ui/components/ui/card'; +import { Table, TableBody, TableCell, TableRow } from '@simple-module/ui/components/ui/table'; import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; import { Activity, Box, Heart, Package, Server, Users } from 'lucide-react'; diff --git a/modules/users/tests/test_models.py b/modules/users/tests/test_models.py index 177d9adf..f38f8597 100644 --- a/modules/users/tests/test_models.py +++ b/modules/users/tests/test_models.py @@ -25,6 +25,7 @@ def column_names(table) -> set[str]: # ── Model structure tests ───────────────────────────────────────────── + class TestUserTableShape: def test_tablename(self): from users.models import User diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index f1b8b0b4..18d4a801 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,7 +1,6 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. - export default { translation: { 'auth.errors.missing_permission': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index a1fb7dec..d0ee311e 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -4,145 +4,145 @@ export const keys = { auth: { errors: { - missing_permission: "auth.errors.missing_permission", - not_authenticated: "auth.errors.not_authenticated", + missing_permission: 'auth.errors.missing_permission', + not_authenticated: 'auth.errors.not_authenticated', }, }, dashboard: { home: { - description: "dashboard.home.description", - description_body: "dashboard.home.description_body", + 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", - total_users: "dashboard.home.stats.total_users", + active_users: 'dashboard.home.stats.active_users', + modules: 'dashboard.home.stats.modules', + products: 'dashboard.home.stats.products', + 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", + 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", + 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', }, }, host: { error: { - forbidden_description: "host.error.forbidden_description", - forbidden_title: "host.error.forbidden_title", - generic_description: "host.error.generic_description", - generic_title: "host.error.generic_title", - go_back: "host.error.go_back", - go_home: "host.error.go_home", - not_found_description: "host.error.not_found_description", - not_found_title: "host.error.not_found_title", - server_error_description: "host.error.server_error_description", - server_error_title: "host.error.server_error_title", + forbidden_description: 'host.error.forbidden_description', + forbidden_title: 'host.error.forbidden_title', + generic_description: 'host.error.generic_description', + generic_title: 'host.error.generic_title', + go_back: 'host.error.go_back', + go_home: 'host.error.go_home', + not_found_description: 'host.error.not_found_description', + not_found_title: 'host.error.not_found_title', + server_error_description: 'host.error.server_error_description', + server_error_title: 'host.error.server_error_title', }, landing: { - badge: "host.landing.badge", - cta_dashboard: "host.landing.cta_dashboard", - cta_docs: "host.landing.cta_docs", - cta_get_started: "host.landing.cta_get_started", + badge: 'host.landing.badge', + cta_dashboard: 'host.landing.cta_dashboard', + cta_docs: 'host.landing.cta_docs', + cta_get_started: 'host.landing.cta_get_started', features: { - auth_description: "host.landing.features.auth_description", - auth_title: "host.landing.features.auth_title", - devtools_description: "host.landing.features.devtools_description", - devtools_title: "host.landing.features.devtools_title", - diagnostics_description: "host.landing.features.diagnostics_description", - diagnostics_title: "host.landing.features.diagnostics_title", - inertia_description: "host.landing.features.inertia_description", - inertia_title: "host.landing.features.inertia_title", - module_system_description: "host.landing.features.module_system_description", - module_system_title: "host.landing.features.module_system_title", - schema_description: "host.landing.features.schema_description", - schema_title: "host.landing.features.schema_title", + auth_description: 'host.landing.features.auth_description', + auth_title: 'host.landing.features.auth_title', + devtools_description: 'host.landing.features.devtools_description', + devtools_title: 'host.landing.features.devtools_title', + diagnostics_description: 'host.landing.features.diagnostics_description', + diagnostics_title: 'host.landing.features.diagnostics_title', + inertia_description: 'host.landing.features.inertia_description', + inertia_title: 'host.landing.features.inertia_title', + module_system_description: 'host.landing.features.module_system_description', + module_system_title: 'host.landing.features.module_system_title', + schema_description: 'host.landing.features.schema_description', + schema_title: 'host.landing.features.schema_title', }, - hero_subtitle: "host.landing.hero_subtitle", - hero_title_line1: "host.landing.hero_title_line1", - hero_title_line2: "host.landing.hero_title_line2", + hero_subtitle: 'host.landing.hero_subtitle', + hero_title_line1: 'host.landing.hero_title_line1', + hero_title_line2: 'host.landing.hero_title_line2', }, }, products: { browse: { - count: "products.browse.count", - count_one: "products.browse.count_one", - count_other: "products.browse.count_other", - create_button: "products.browse.create_button", - description: "products.browse.description", - empty_description: "products.browse.empty_description", - empty_title: "products.browse.empty_title", - new_button: "products.browse.new_button", - no_match: "products.browse.no_match", - search_placeholder: "products.browse.search_placeholder", - title: "products.browse.title", + count: 'products.browse.count', + count_one: 'products.browse.count_one', + count_other: 'products.browse.count_other', + create_button: 'products.browse.create_button', + description: 'products.browse.description', + empty_description: 'products.browse.empty_description', + empty_title: 'products.browse.empty_title', + new_button: 'products.browse.new_button', + no_match: 'products.browse.no_match', + search_placeholder: 'products.browse.search_placeholder', + title: 'products.browse.title', }, create: { - description: "products.create.description", - submit_button: "products.create.submit_button", - submitting_button: "products.create.submitting_button", - title: "products.create.title", + description: 'products.create.description', + submit_button: 'products.create.submit_button', + submitting_button: 'products.create.submitting_button', + title: 'products.create.title', }, delete_dialog: { - cancel: "products.delete_dialog.cancel", - confirm: "products.delete_dialog.confirm", - description: "products.delete_dialog.description", - title: "products.delete_dialog.title", + cancel: 'products.delete_dialog.cancel', + confirm: 'products.delete_dialog.confirm', + description: 'products.delete_dialog.description', + title: 'products.delete_dialog.title', }, edit: { - back_button: "products.edit.back_button", - description: "products.edit.description", - submit_button: "products.edit.submit_button", - submitting_button: "products.edit.submitting_button", - title: "products.edit.title", + back_button: 'products.edit.back_button', + description: 'products.edit.description', + submit_button: 'products.edit.submit_button', + submitting_button: 'products.edit.submitting_button', + title: 'products.edit.title', }, errors: { - not_found: "products.errors.not_found", + not_found: 'products.errors.not_found', }, form: { - active_label: "products.form.active_label", - cancel_button: "products.form.cancel_button", - description_label: "products.form.description_label", - description_placeholder: "products.form.description_placeholder", - name_label: "products.form.name_label", - name_placeholder: "products.form.name_placeholder", - price_label: "products.form.price_label", - price_placeholder: "products.form.price_placeholder", + active_label: 'products.form.active_label', + cancel_button: 'products.form.cancel_button', + description_label: 'products.form.description_label', + description_placeholder: 'products.form.description_placeholder', + name_label: 'products.form.name_label', + name_placeholder: 'products.form.name_placeholder', + price_label: 'products.form.price_label', + price_placeholder: 'products.form.price_placeholder', }, table: { - actions: "products.table.actions", - active: "products.table.active", - description: "products.table.description", - inactive: "products.table.inactive", - name: "products.table.name", - price: "products.table.price", - status: "products.table.status", + actions: 'products.table.actions', + active: 'products.table.active', + description: 'products.table.description', + inactive: 'products.table.inactive', + name: 'products.table.name', + price: 'products.table.price', + status: 'products.table.status', }, toasts: { - created: "products.toasts.created", - delete_failed: "products.toasts.delete_failed", - deleted: "products.toasts.deleted", - updated: "products.toasts.updated", + created: 'products.toasts.created', + delete_failed: 'products.toasts.delete_failed', + deleted: 'products.toasts.deleted', + updated: 'products.toasts.updated', }, validation: { - name_required: "products.validation.name_required", - name_too_long: "products.validation.name_too_long", - price_positive: "products.validation.price_positive", - price_required: "products.validation.price_required", + name_required: 'products.validation.name_required', + name_too_long: 'products.validation.name_too_long', + price_positive: 'products.validation.price_positive', + price_required: 'products.validation.price_required', }, }, ui: { errors: { - generic_description: "ui.errors.generic_description", - generic_title: "ui.errors.generic_title", - go_home_button: "ui.errors.go_home_button", - reload_button: "ui.errors.reload_button", + generic_description: 'ui.errors.generic_description', + generic_title: 'ui.errors.generic_title', + go_home_button: 'ui.errors.go_home_button', + reload_button: 'ui.errors.reload_button', }, switcher: { - label: "ui.switcher.label", + label: 'ui.switcher.label', }, }, } as const;