feat(admin): add /admin dashboard with analytics, moderation, health - #156
feat(admin): add /admin dashboard with analytics, moderation, health#156rafmacalaba wants to merge 6 commits into
Conversation
Phases 0-3 per REQUIREMENTS-admin-dashboard.md:
Phase 0 - Admin Auth Foundation
- ADMIN_EMAILS env var, require_admin dep, canViewAdmin flag in /api/auth/me
- Disabled-user block on login, guest, refresh
- /admin/* layout with auth gate, sidebar (Analytics/Moderation/Health/Review feedback)
- Dashboard menu item in user nav for admins
Phase 1 - Analytics Dashboard
- GET /api/admin/analytics/{users,chats,feedback,tokens} with date range filter
- Frontend: stat cards, 5 chart families, date filter, /admin/analytics page
- Token usage block (totalTokens, promptTokens, completionTokens, tokensByModel, cost)
Phase 2 - Moderation Panel
- GET /api/admin/moderation/{users,chats} paginated, with search
- POST /disable toggle, DELETE /chats/{id} soft delete (deletedAt)
- Frontend: Users tab + Chats tab with search, pagination, actions
Phase 3 - System Health Dashboard
- GET /api/admin/health (status, db, mcp, uptimeSeconds) with MCP async-with fix
- GET /api/admin/health/metrics (8 fields incl. totalTokens24h, tokenRate24h, costEstimate24h)
- Frontend: status cards, metrics grid, 30s auto-refresh
Cross-cutting
- Rate-limit exemption for /api/admin/*
- ChatTokenUsage model + 2 alembic migrations (deletedAt + ChatTokenUsage)
- Admin canViewTokenUsage flag (admin supersedes reviewer)
- require_feedback_reviewer accepts admin OR reviewer emails
- /review/* auth-gated shell (ReviewShell) for sole reviewers
- /register + /login: bounce fix + optional 'Continue as guest' path (MSAL untouched)
- 90 backend tests + 33 frontend unit tests + 18 e2e tests
- docs/admin-api.md, CHANGELOG.md, README.md updated
- .gitignore: ignore armada/, .opencode/, .agents/, DEFECTS.md, ADVERSARIAL_REVIEW.md, screenshots/, eval artifacts
fb12aa1 to
a947f2f
Compare
avsolatorio
left a comment
There was a problem hiding this comment.
Starting with this one, @rafmacalaba . :)
|
|
||
| now = datetime.utcnow() | ||
|
|
||
| # Hard-delete votes and streams |
There was a problem hiding this comment.
We should opt for soft deletion whenever possible. The Vote table, in particular, is very important because it contains feedback.
There was a problem hiding this comment.
ack! fixed on the latest commit.
There was a problem hiding this comment.
Thanks @avsolatorio — addressed, and took the principle further than just Vote.
Done in this round (3 new commits on feat/admin-dashboard):
- Vote: soft-delete via new deletedAt column (migration n5o6p7q8r9s0). On re-vote the existing row reactivates on the same composite PK — no duplicates, no feedback loss.
- Stream: same treatment (migration o6p7q8r9s0t1). Stream rows are needed to resume from Redis, so preserving them matters for audit.
- Message was already soft-deletable in the base PR.
Caught while verifying:
- Admin analytics was counting soft-deleted votes/chats, which would have inflated the upvote ratio and total-chats numbers. Now filters deletedAt IS NULL.
- Verified live against the deployed chatbot: soft-delete a chat → vote row persists with deletedAt set, re-vote reuses the same PK, no duplicate, chat hidden from lists. 45 backend tests green.
The only paths that still hard-delete are delete_chat_by_id / delete_chats_by_user_id — those are by design (user-initiated / GDPR flows), kept distinct from admin moderation.
Address review feedback: Vote rows contain user feedback and must survive chat deletion for analytics. Adds deletedAt column to Vote_v2 (migration n5o6p7q8r9s0), switches soft_delete_chat_by_id to update votes instead of hard-deleting, and reactivates the existing row on re-vote via composite PK. Stream table left hard-deleted (reviewer scoped feedback to Vote). Verified live: vote row persists with deletedAt set; re-vote reuses same PK without duplicate.
| ) | ||
|
|
||
| # Hard-delete streams (no soft-delete column on Stream) | ||
| await session.execute(delete(Stream).where(Stream.chatId == chat_id)) |
There was a problem hiding this comment.
Please also soft-delete this. Thanks!
There was a problem hiding this comment.
Done in 7c40e6e. Stream now soft-deletes alongside Message and Vote — same deletedAt pattern, migration o6p7q8r9s0t1. soft_delete_chat_by_id updates Stream instead of deleting it. Test test_delete_chat_soft_deletes_streams proves the row stays in the DB with deletedAt set after chat deletion. Only delete_chat_by_id / delete_chats_by_user_id still hard-delete — those are by-design user-initiated paths.
|
|
||
| id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) | ||
| chat_id = Column(UUID(as_uuid=True), nullable=False, name="chatId") | ||
| message_id = Column(String(64), nullable=False, name="messageId") |
There was a problem hiding this comment.
Shouldn't this be a UUID type as well?
There was a problem hiding this comment.
Fixed in c420f55. ChatTokenUsage.messageId is now UUID(as_uuid=True) to match the FK relationship with Message_v2.id. Migration p7q8r9s0t1u2 converts in place — pre-checked all existing rows (4/4) were valid UUID strings before the ALTER, so the conversion is lossless. background_tasks.py wraps the incoming message_id in UUID() at the boundary.
There was a problem hiding this comment.
You may also add the "admin" tag here. And remap the endpoint to "/api/admin/feedback".
There was a problem hiding this comment.
Fixed in c420f55. /api/feedback → /api/admin/feedback with the "admin" OpenAPI tag, matching the other admin routers. Old path returns 404; new path is auth-gated like the rest. Migration p7q8r9s0t1u2 also bumps ChatTokenUsage.messageId from String(64) to UUID, addressing your other comment on chat_token_usage.py:20 — all existing rows were already valid UUIDs so the conversion is lossless. 89 tests green, live smoke confirms the new path is alive and the old one is gone.
| """Lifespan context manager for startup and shutdown events.""" | ||
| # Startup | ||
| logger.info("=== FastAPI app startup ===") | ||
| # Dev fallback: create tables for sqlite when migrations are not applied |
There was a problem hiding this comment.
Make sure the app environment is set to development before automatically creating this fallback db.
There was a problem hiding this comment.
Fixed in 6290bf0. The old gate matched the sqlite filename in DATABASE_URL — a misconfigured prod URL containing 'chat.sqlite' would still trigger create_all against the real Postgres. New code gates on settings.ENVIRONMENT in (development, local, dev, test) first, then keeps the sqlite path sniff as a second guard. 9 tests in test_main.py cover: production skips regardless of URL, development+sqlite passes, development+postgres skips (don't create_all against prod-shaped URLs), staging skips. Live ENVIRONMENT=development confirmed.
The soft-delete change in af93fe1 (Vote, Message, Chat) left admin analytics counting soft-deleted rows. Add deletedAt.is_(None) to all vote/chat/token queries in analytics.py. Tests assert the generated SQL contains the filter.
Symmetric with the Vote/Message soft-delete from af93fe1. Stream rows contain the stream_id used to resume streaming from Redis and should be preserved on chat deletion for audit. Adds deletedAt column to Stream (migration o6p7q8r9s0t1, revises n5o6p7q8r9s0) and switches soft_delete_chat_by_id to update Stream instead of hard-deleting. Hard-delete variants delete_chat_by_id and delete_chats_by_user_id are unchanged (still hard-delete by design).
Two review comments from @avsolatorio: 1. main.py:207 — feedback router now lives under /api/admin/feedback with the 'admin' OpenAPI tag, consistent with the other admin routers (analytics/moderation/health). Old /api/feedback path removed (no backwards compat — dev/staging only). 2. chat_token_usage.py:20 — messageId was String(64), now UUID to match the FK relationship to Message_v2.id. Migration p7q8r9s0t1u2 converts in place (verified all 4 existing rows were valid UUIDs before the conversion). background_tasks.py now wraps the incoming message_id in UUID() at the boundary. 89 tests pass, live smoke confirms /api/admin/feedback/review returns 401 (auth-gated, route alive) and /api/feedback/review returns 404 (old path gone).
Address @avsolatorio comment on main.py:115. The old gate matched 'chat.sqlite' / 'chatbot.db' in DATABASE_URL — a misconfigured production URL containing those substrings would trigger create_all against the real Postgres. Now the FIRST guard is settings.ENVIRONMENT in (development, local, dev, test); the sqlite path sniff stays as a second guard. 9 new tests in test_main.py cover: production skips regardless of URL, development+sqlite passes, development+postgres skips (don't create_all against prod-shaped URLs), staging skips, all four env values verified. Live ENVIRONMENT=development confirmed via docker compose exec.
Summary
Adds the
/admindashboard (Analytics · Moderation · Health) with supporting/reviewshell for sole reviewers and an optional "Continue as guest" path on/loginand/register. MSAL/AzureAD flow is untouched.What ships (50 files, +6263 / −156)
Backend (12 files)
app/api/v1/admin/{analytics,moderation,health}.py— full rewritesapp/api/v1/auth.py—canViewTokenUsageflag (admin supersedes reviewer),canViewAdminflagapp/api/deps.py—require_feedback_revieweraccepts admin OR reviewerapp/api/v1/utils/background_tasks.py—ChatTokenUsagerecordingapp/core/rate_limit.py—/api/admin/*exemptapp/main.py— dev-onlyBase.metadata.create_allgateapp/models/chat.py—deletedAtcolumnapp/models/chat_token_usage.py— new modelalembic/versions/l3m4n5o6p7q8_*.py—deletedAtmigrationalembic/versions/m4n5o6p7q8r9_*.py—ChatTokenUsagemigrationtest_admin_{auth,analytics,moderation,health,rate_limit_exempt}.py,test_role_matrix.pyFrontend (15 files)
app/(admin)/admin/{analytics,moderation,health}/page.tsx— full rewritesapp/(admin)/layout.tsx— sidebar + 403 gate + "Review feedback" link (gated oncanViewTokenUsage)app/(auth)/{login,register}/page.tsx— bounce fix, optional "Continue as guest"app/review/{layout.tsx, review-shell.tsx}— auth-gated shell for sole reviewerscomponents/sidebar-user-nav.tsx— Dashboard menu item (gated oncanViewAdmin)lib/admin/{analytics,nav}.ts,lib/auth/registration.ts— new helperslib/{auth-service, auth-service-client}.ts—AuthErrorclass, verbose toastslib/__tests__/E2E + Docs
e2e/admin-dashboard.spec.ts— 18 Playwright testsdocs/admin-api.md— full/api/admin/*referenceCHANGELOG.md— Unreleased entryREADME.md— Admin Dashboard section.gitignore— ignoresarmada/,.opencode/,.agents/,DEFECTS.md,ADVERSARIAL_REVIEW.md,screenshots/, eval artifactsMigration (run before deploy)
Applies:
User.disabledcolumn (already in upstream via9bab245e8167),Chat.deletedAt(newl3m4n5o6p7q8),ChatTokenUsagetable (newm4n5o6p7q8r9).Config (set in prod
.env)ADMIN_EMAILS=real-admin@org.com(currentlyadmin@test.comfor local)JWT_SECRET_KEY=<rotate to a real 32-byte secret>(currently a placeholder)FEEDBACK_REVIEWER_EMAILS=...— keep as-isOPENAI_API_KEY+ changeMODEL_PROVIDERAuth model
canViewAdminADMIN_EMAILScanViewTokenUsageADMIN_EMAILS∪FEEDBACK_REVIEWER_EMAILS(admin supersedes)/api/admin/*routes guarded byDepends(require_admin)/api/admin/*exempt from global rate limit (admin-gated)Reviewer integration
The PR widens the existing
/review/feedbackreviewer-only flow so admins can also use it, and adds a sidebar shell for sole reviewers. The/api/feedback/review*endpoints themselves are unchanged — only the auth gate and the UI shell are new.Role matrix
ADMIN_EMAILS?FEEDBACK_REVIEWER_EMAILS?canViewAdmincanViewTokenUsage/admin/*/review/feedbackAuth gate widening
app/api/deps.py—require_feedback_reviewernow accepts an email inADMIN_EMAILS∪FEEDBACK_REVIEWER_EMAILS. When both lists are empty, the dep still returns 403 ("reviewer access is not configured").app/api/v1/auth.py—canViewTokenUsagein/api/auth/meisadmin ∪ reviewer(admin supersedes).Cross-link in the admin sidebar
app/(admin)/layout.tsx— sidebar shows a "Review feedback" item gated oncanViewTokenUsage === true. Admin users see it because the flag istruefor them. Sole reviewers don't see the admin sidebar at all (they get a 403 on/admin).Shell for sole reviewers
app/review/review-shell.tsx— auth-gated shell wrapping the existing/review/feedbackpages. Mirrors the admin layout's look-and-feel. Sidebar shows "Feedback" + "Back to Chat"; conditionally shows "Admin Dashboard" if the user is also an admin. Inline 403 ifcanViewTokenUsage !== true.Out of scope (deferred)
/api/feedback/review*endpoints — unchanged. Reviewers see the same data as before.FEEDBACK_REVIEWER_EMAILSenv var — unchanged. The dep just also accepts admins now.MSAL / AzureAD
Unchanged. When
NEXT_PUBLIC_AUTH_PROVIDER=msal, the existingproxy.tsmiddleware handles auth as before; the login page still renders the "Sign in with Microsoft" button. The bounce fix on/login//registeronly affectsguestandusermodes.Test gates
Out of scope (deferred to a follow-up)
/api/admin/*rate-limit carve-out enables DB hammerChatTokenUsageunbounded growth, no FK, no cleanup