feat(admin): replace mock dashboard stats with real /admin/stats endpoint

The /admin route in the unified Mana web app was rendering hardcoded
mock data (42 users, 156 successful logins, 3 failed) for every
admin who opened it. The previous code had a TODO comment to wire
up a real endpoint and the backend half had been waiting for the
frontend half ever since the consolidation landed.

Backend (mana-auth):
  Add GET /api/v1/admin/stats — admin-only, returns the seven counts
  the dashboard needs in a single response. Each count is its own
  Drizzle query against auth.users / auth.sessions / auth.login_
  attempts; they run in parallel via Promise.all so total latency is
  dominated by the round-trip to Postgres, not the per-query work.

  Stats:
    - totalUsers      → users where deleted_at IS NULL
    - newUsers7d      → users created in the last 7 days
    - newUsers30d     → users created in the last 30 days
    - activeSessions  → sessions where expires_at > now() AND not revoked
    - uniqueUsers24h  → distinct user_id from sessions with last_activity
                        in the last 24h (and not revoked)
    - loginSuccess7d  → login_attempts where successful=true, last 7d
    - loginFailed7d   → login_attempts where successful=false, last 7d

  Plus a generatedAt ISO timestamp so the client can show staleness
  if it ever caches the response.

Frontend (apps/mana/apps/web):
  - Add adminService.getStats() in the existing admin API service
    (sits next to getUsers / getUserData / deleteUserData; uses the
    same authenticated base-client and ApiResult envelope).
  - Replace the onMount mock-data block in admin/+page.svelte with
    a single adminService.getStats() call. Drop the local Stats
    interface in favor of the AdminStats type exported from the
    service.
  - Guard the Success Rate calculation against division by zero on
    fresh deployments — when there have been no login attempts in
    the last 7 days, render '—%' instead of NaN%.

Verification:
  - mana-auth type-check unchanged (baseline errors only)
  - mana-auth runtime tests still 19/19 passing
  - svelte-check on the two changed web files: zero errors

Closes item #12 in docs/REFACTORING_AUDIT_2026_04.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 12:20:18 +02:00
parent 7b25f3abfb
commit fbb71f9366
3 changed files with 109 additions and 36 deletions

View file

@ -146,10 +146,34 @@ export interface DeleteUserDataResponse {
totalDeleted: number;
}
/**
* Admin dashboard stats aggregate counts from auth.users,
* auth.sessions, auth.login_attempts. Backed by GET /admin/stats.
*/
export interface AdminStats {
totalUsers: number;
newUsers7d: number;
newUsers30d: number;
activeSessions: number;
uniqueUsers24h: number;
loginSuccess7d: number;
loginFailed7d: number;
/** ISO timestamp of when the snapshot was generated server-side */
generatedAt: string;
}
/**
* Admin service for user data management
*/
export const adminService = {
/**
* Aggregate dashboard stats. Backed by a single mana-auth endpoint
* that runs seven counts against the auth schema in parallel.
*/
async getStats(): Promise<ApiResult<AdminStats>> {
return getClient().get<AdminStats>('/admin/stats');
},
/**
* Get list of users with pagination and search
*/

View file

@ -2,18 +2,9 @@
import { onMount } from 'svelte';
import StatCard from '$lib/components/admin/StatCard.svelte';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
import { adminService, type AdminStats } from '$lib/api/services/admin';
interface Stats {
totalUsers: number;
newUsers7d: number;
newUsers30d: number;
activeSessions: number;
uniqueUsers24h: number;
loginSuccess7d: number;
loginFailed7d: number;
}
let stats = $state<Stats | null>(null);
let stats = $state<AdminStats | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
@ -45,27 +36,13 @@
];
onMount(async () => {
try {
// TODO: Replace with actual API call to fetch admin stats
// const response = await fetch('/api/admin/stats');
// stats = await response.json();
// Mock data for now
await new Promise((resolve) => setTimeout(resolve, 500));
stats = {
totalUsers: 42,
newUsers7d: 8,
newUsers30d: 23,
activeSessions: 15,
uniqueUsers24h: 12,
loginSuccess7d: 156,
loginFailed7d: 3,
};
} catch (e) {
error = 'Failed to load stats';
} finally {
loading = false;
const result = await adminService.getStats();
if (result.error) {
error = result.error;
} else {
stats = result.data;
}
loading = false;
});
let userGrowthPercent = $derived(
@ -131,9 +108,11 @@
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Success Rate</span>
<span class="font-medium text-green-600">
{Math.round(
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
)}%
{stats.loginSuccess7d + stats.loginFailed7d > 0
? Math.round(
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
)
: '—'}%
</span>
</div>
</div>

View file

@ -5,10 +5,11 @@
*/
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { and, count, countDistinct, eq, gte, isNull } from 'drizzle-orm';
import type { AuthUser } from '../middleware/jwt-auth';
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { users } from '../db/schema/auth';
import { users, sessions } from '../db/schema/auth';
import { loginAttempts } from '../db/schema/login-attempts';
import type { UserDataService } from '../services/user-data';
const VALID_TIERS = ['guest', 'public', 'beta', 'alpha', 'founder'] as const;
@ -26,6 +27,75 @@ export function createAdminRoutes(db: PostgresJsDatabase<any>, userDataService:
await next();
});
// ─── Aggregate stats for the admin dashboard ──────────────
//
// Replaces hardcoded mock data in apps/mana/apps/web/src/routes/
// (app)/admin/+page.svelte. All seven values come from auth.users,
// auth.sessions and auth.login_attempts — no other service is
// involved, which keeps this endpoint a pure single-DB read.
app.get('/stats', async (c) => {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// One query per stat — Postgres handles them in parallel via
// the connection pool when wrapped in Promise.all. Each one is
// a single indexed count, so total latency is dominated by
// round-trip not query work.
const [
[{ value: totalUsers }],
[{ value: newUsers7d }],
[{ value: newUsers30d }],
[{ value: activeSessions }],
[{ value: uniqueUsers24h }],
[{ value: loginSuccess7d }],
[{ value: loginFailed7d }],
] = await Promise.all([
db.select({ value: count() }).from(users).where(isNull(users.deletedAt)),
db
.select({ value: count() })
.from(users)
.where(and(isNull(users.deletedAt), gte(users.createdAt, sevenDaysAgo))),
db
.select({ value: count() })
.from(users)
.where(and(isNull(users.deletedAt), gte(users.createdAt, thirtyDaysAgo))),
db
.select({ value: count() })
.from(sessions)
.where(and(gte(sessions.expiresAt, now), isNull(sessions.revokedAt))),
db
.select({ value: countDistinct(sessions.userId) })
.from(sessions)
.where(and(isNull(sessions.revokedAt), gte(sessions.lastActivityAt, twentyFourHoursAgo))),
db
.select({ value: count() })
.from(loginAttempts)
.where(
and(eq(loginAttempts.successful, true), gte(loginAttempts.attemptedAt, sevenDaysAgo))
),
db
.select({ value: count() })
.from(loginAttempts)
.where(
and(eq(loginAttempts.successful, false), gte(loginAttempts.attemptedAt, sevenDaysAgo))
),
]);
return c.json({
totalUsers,
newUsers7d,
newUsers30d,
activeSessions,
uniqueUsers24h,
loginSuccess7d,
loginFailed7d,
generatedAt: now.toISOString(),
});
});
// ─── List users with pagination and search ────────────────
app.get('/users', async (c) => {