From 86e31cfa5c9da0400efe568e6cc7dc2e5ca31368 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 13:44:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20GET=20/admin/usage=20endpoint?= =?UTF-8?q?=20with=20today/week/month=20token=20aggre=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/routers/admin.py" - "frontend/src/api/admin-usage.ts" - "frontend/src/pages/AdminUsage.tsx" - "frontend/src/pages/AdminUsage.module.css" - "frontend/src/App.tsx" - "frontend/src/components/AdminDropdown.tsx" - "frontend/src/api/index.ts" GSD-Task: S04/T02 --- backend/routers/admin.py | 161 ++++++++++++++++++++- frontend/src/App.tsx | 2 + frontend/src/api/admin-usage.ts | 52 +++++++ frontend/src/api/index.ts | 1 + frontend/src/components/AdminDropdown.tsx | 8 ++ frontend/src/pages/AdminUsage.module.css | 167 ++++++++++++++++++++++ frontend/src/pages/AdminUsage.tsx | 153 ++++++++++++++++++++ 7 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 frontend/src/api/admin-usage.ts create mode 100644 frontend/src/pages/AdminUsage.module.css create mode 100644 frontend/src/pages/AdminUsage.tsx diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 08e3797..7883df4 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -1,15 +1,15 @@ -"""Admin router — user management and impersonation.""" +"""Admin router — user management, impersonation, and usage analytics.""" from __future__ import annotations import logging -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased @@ -20,7 +20,7 @@ from auth import ( require_role, ) from database import get_session -from models import ImpersonationLog, User, UserRole +from models import ChatUsageLog, ImpersonationLog, User, UserRole logger = logging.getLogger("chrysopedia.admin") @@ -262,3 +262,156 @@ async def extract_creator_profile( logger.info("Queued personality extraction for creator=%s (%s)", slug, creator.id) return {"status": "queued", "creator_id": str(creator.id)} + + +# ── Usage Analytics ────────────────────────────────────────────────────────── + + +class _PeriodStats(BaseModel): + request_count: int + total_tokens: int + prompt_tokens: int + completion_tokens: int + + +class _CreatorUsage(BaseModel): + creator_slug: str + request_count: int + total_tokens: int + + +class _UserUsage(BaseModel): + identifier: str # display_name or IP + request_count: int + total_tokens: int + + +class _DailyCount(BaseModel): + date: str # ISO date YYYY-MM-DD + request_count: int + + +class UsageStatsResponse(BaseModel): + today: _PeriodStats + week: _PeriodStats + month: _PeriodStats + top_creators: list[_CreatorUsage] + top_users: list[_UserUsage] + daily_counts: list[_DailyCount] + + +async def _period_stats( + session: AsyncSession, since: datetime, +) -> _PeriodStats: + """Aggregate token stats for chat usage since a given timestamp.""" + stmt = select( + func.count().label("cnt"), + func.coalesce(func.sum(ChatUsageLog.total_tokens), 0).label("total"), + func.coalesce(func.sum(ChatUsageLog.prompt_tokens), 0).label("prompt"), + func.coalesce(func.sum(ChatUsageLog.completion_tokens), 0).label("completion"), + ).where(ChatUsageLog.created_at >= since) + row = (await session.execute(stmt)).one() + return _PeriodStats( + request_count=row.cnt, + total_tokens=row.total, + prompt_tokens=row.prompt, + completion_tokens=row.completion, + ) + + +@router.get("/usage", response_model=UsageStatsResponse) +async def get_usage_stats( + _admin: Annotated[User, Depends(_require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """Aggregated chat usage statistics. Admin only.""" + now = datetime.now(timezone.utc).replace(tzinfo=None) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - timedelta(days=today_start.weekday()) # Monday + month_start = today_start.replace(day=1) + + today = await _period_stats(session, today_start) + week = await _period_stats(session, week_start) + month = await _period_stats(session, month_start) + + # Top 10 creators by total tokens (this month) + creator_stmt = ( + select( + ChatUsageLog.creator_slug, + func.count().label("cnt"), + func.coalesce(func.sum(ChatUsageLog.total_tokens), 0).label("total"), + ) + .where( + ChatUsageLog.created_at >= month_start, + ChatUsageLog.creator_slug.isnot(None), + ) + .group_by(ChatUsageLog.creator_slug) + .order_by(func.sum(ChatUsageLog.total_tokens).desc()) + .limit(10) + ) + creator_rows = (await session.execute(creator_stmt)).all() + top_creators = [ + _CreatorUsage(creator_slug=r.creator_slug, request_count=r.cnt, total_tokens=r.total) + for r in creator_rows + ] + + # Top 10 users by request count (this month) + # Join with users table to get display_name; fall back to IP for anonymous + user_stmt = ( + select( + ChatUsageLog.user_id, + ChatUsageLog.client_ip, + func.count().label("cnt"), + func.coalesce(func.sum(ChatUsageLog.total_tokens), 0).label("total"), + ) + .where(ChatUsageLog.created_at >= month_start) + .group_by(ChatUsageLog.user_id, ChatUsageLog.client_ip) + .order_by(func.count().desc()) + .limit(10) + ) + user_rows = (await session.execute(user_stmt)).all() + + # Resolve user display names + user_ids = [r.user_id for r in user_rows if r.user_id is not None] + name_map: dict[str, str] = {} + if user_ids: + name_result = await session.execute( + select(User.id, User.display_name).where(User.id.in_(user_ids)) + ) + for uid, name in name_result.all(): + name_map[str(uid)] = name + + top_users = [ + _UserUsage( + identifier=name_map.get(str(r.user_id), r.client_ip or "anonymous") + if r.user_id + else (r.client_ip or "anonymous"), + request_count=r.cnt, + total_tokens=r.total, + ) + for r in user_rows + ] + + # Daily request counts for last 7 days + seven_days_ago = today_start - timedelta(days=6) + day_col = func.date_trunc("day", ChatUsageLog.created_at).label("day") + daily_stmt = ( + select(day_col, func.count().label("cnt")) + .where(ChatUsageLog.created_at >= seven_days_ago) + .group_by(day_col) + .order_by(day_col) + ) + daily_rows = (await session.execute(daily_stmt)).all() + daily_counts = [ + _DailyCount(date=r.day.strftime("%Y-%m-%d"), request_count=r.cnt) + for r in daily_rows + ] + + return UsageStatsResponse( + today=today, + week=week, + month=month, + top_creators=top_creators, + top_users=top_users, + daily_counts=daily_counts, + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6258fd0..4b5b3bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard")); const WatchPage = React.lazy(() => import("./pages/WatchPage")); const AdminUsers = React.lazy(() => import("./pages/AdminUsers")); const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog")); +const AdminUsage = React.lazy(() => import("./pages/AdminUsage")); const ChatPage = React.lazy(() => import("./pages/ChatPage")); const ChapterReview = React.lazy(() => import("./pages/ChapterReview")); const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue")); @@ -197,6 +198,7 @@ function AppShell() { }>} /> }>} /> }>} /> + }>} /> {/* Info routes */} }>} /> diff --git a/frontend/src/api/admin-usage.ts b/frontend/src/api/admin-usage.ts new file mode 100644 index 0000000..7be33ba --- /dev/null +++ b/frontend/src/api/admin-usage.ts @@ -0,0 +1,52 @@ +/** + * Admin usage stats API client. + */ + +import { BASE, ApiError } from "./client"; + +export interface PeriodStats { + request_count: number; + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; +} + +export interface CreatorUsage { + creator_slug: string; + request_count: number; + total_tokens: number; +} + +export interface UserUsage { + identifier: string; + request_count: number; + total_tokens: number; +} + +export interface DailyCount { + date: string; + request_count: number; +} + +export interface UsageStats { + today: PeriodStats; + week: PeriodStats; + month: PeriodStats; + top_creators: CreatorUsage[]; + top_users: UserUsage[]; + daily_counts: DailyCount[]; +} + +export async function fetchUsageStats(token: string): Promise { + const res = await fetch(`${BASE}/admin/usage`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new ApiError(res.status, body.detail || res.statusText); + } + return res.json(); +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 541ddad..74f092f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -16,3 +16,4 @@ export * from "./auth"; export * from "./creator-dashboard"; export * from "./consent"; export * from "./follows"; +export * from "./admin-usage"; diff --git a/frontend/src/components/AdminDropdown.tsx b/frontend/src/components/AdminDropdown.tsx index be43a49..5971494 100644 --- a/frontend/src/components/AdminDropdown.tsx +++ b/frontend/src/components/AdminDropdown.tsx @@ -126,6 +126,14 @@ export default function AdminDropdown() { > Audit Log + setOpen(false)} + > + Usage + )} diff --git a/frontend/src/pages/AdminUsage.module.css b/frontend/src/pages/AdminUsage.module.css new file mode 100644 index 0000000..ec4d67d --- /dev/null +++ b/frontend/src/pages/AdminUsage.module.css @@ -0,0 +1,167 @@ +.page { + max-width: 1000px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.5rem; + color: var(--text-primary, #e2e8f0); +} + +/* ── Summary Cards ─────────────────────────────────────── */ + +.summaryGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.card { + background: var(--color-surface, #1a1a2e); + border: 1px solid var(--color-border, #2d2d3d); + border-radius: 8px; + padding: 1.25rem; +} + +.cardLabel { + display: block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, #828291); + margin-bottom: 0.5rem; +} + +.cardValue { + display: block; + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #e2e8f0); + font-variant-numeric: tabular-nums; +} + +.cardSub { + display: block; + font-size: 0.8rem; + color: var(--text-secondary, #828291); + margin-top: 0.25rem; + font-variant-numeric: tabular-nums; +} + +/* ── Section ───────────────────────────────────────────── */ + +.section { + margin-bottom: 2rem; +} + +.sectionTitle { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary, #e2e8f0); + margin-bottom: 1rem; +} + +/* ── Tables ────────────────────────────────────────────── */ + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.table th { + text-align: left; + padding: 0.6rem 0.75rem; + border-bottom: 2px solid var(--color-border, #2d2d3d); + color: var(--text-secondary, #828291); + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.table td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--color-border, #2d2d3d); + color: var(--text-primary, #e2e8f0); + font-variant-numeric: tabular-nums; +} + +.numCell { + text-align: right; +} + +/* ── Bar Chart ─────────────────────────────────────────── */ + +.chartContainer { + display: flex; + align-items: flex-end; + gap: 0.5rem; + height: 120px; + padding-top: 0.5rem; +} + +.barColumn { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + height: 100%; + justify-content: flex-end; +} + +.bar { + width: 100%; + max-width: 48px; + background: var(--color-accent, #22d3ee); + border-radius: 4px 4px 0 0; + min-height: 2px; + transition: height 300ms ease; +} + +.barLabel { + font-size: 0.7rem; + color: var(--text-secondary, #828291); + margin-top: 0.35rem; + text-align: center; + white-space: nowrap; +} + +.barValue { + font-size: 0.7rem; + color: var(--text-primary, #e2e8f0); + margin-bottom: 0.25rem; + font-variant-numeric: tabular-nums; +} + +/* ── States ────────────────────────────────────────────── */ + +.loading, +.error, +.empty { + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary, #828291); +} + +.error { + color: #ef4444; +} + +/* ── Responsive ────────────────────────────────────────── */ + +@media (max-width: 600px) { + .summaryGrid { + grid-template-columns: 1fr; + } + + .table th:nth-child(3), + .table td:nth-child(3) { + display: none; + } +} diff --git a/frontend/src/pages/AdminUsage.tsx b/frontend/src/pages/AdminUsage.tsx new file mode 100644 index 0000000..f2cb0a8 --- /dev/null +++ b/frontend/src/pages/AdminUsage.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "../context/AuthContext"; +import { fetchUsageStats, type UsageStats } from "../api/admin-usage"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import styles from "./AdminUsage.module.css"; + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +function shortDay(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00"); + return d.toLocaleDateString(undefined, { weekday: "short" }); +} + +export default function AdminUsage() { + useDocumentTitle("Usage — Admin"); + const { token } = useAuth(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) return; + setLoading(true); + fetchUsageStats(token) + .then(setStats) + .catch((e) => setError(e.message || "Failed to load usage stats")) + .finally(() => setLoading(false)); + }, [token]); + + if (loading) { + return ( +
+

Usage

+

Loading usage stats…

+
+ ); + } + + if (error || !stats) { + return ( +
+

Usage

+

{error || "No data available"}

+
+ ); + } + + const maxDaily = Math.max(...stats.daily_counts.map((d) => d.request_count), 1); + + return ( +
+

Usage

+ + {/* Summary cards */} +
+ {([ + { label: "Today", data: stats.today }, + { label: "This Week", data: stats.week }, + { label: "This Month", data: stats.month }, + ] as const).map(({ label, data }) => ( +
+ {label} + + {formatNumber(data.total_tokens)} tokens + + + {data.request_count} request{data.request_count !== 1 ? "s" : ""} + {" · "} + {formatNumber(data.prompt_tokens)} prompt / {formatNumber(data.completion_tokens)} completion + +
+ ))} +
+ + {/* Daily bar chart */} + {stats.daily_counts.length > 0 && ( +
+

Last 7 Days

+
+ {stats.daily_counts.map((d) => ( +
+ {d.request_count} +
+ {shortDay(d.date)} +
+ ))} +
+
+ )} + + {/* Top creators */} +
+

Top Creators (This Month)

+ {stats.top_creators.length === 0 ? ( +

No creator usage data yet.

+ ) : ( + + + + + + + + + + {stats.top_creators.map((c) => ( + + + + + + ))} + +
CreatorRequestsTokens
{c.creator_slug}{c.request_count.toLocaleString()}{formatNumber(c.total_tokens)}
+ )} +
+ + {/* Top users */} +
+

Top Users (This Month)

+ {stats.top_users.length === 0 ? ( +

No user usage data yet.

+ ) : ( + + + + + + + + + + {stats.top_users.map((u, i) => ( + + + + + + ))} + +
User / IPRequestsTokens
{u.identifier}{u.request_count.toLocaleString()}{formatNumber(u.total_tokens)}
+ )} +
+
+ ); +}