feat: Added GET /admin/usage endpoint with today/week/month token aggre…

- "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
This commit is contained in:
jlightner 2026-04-04 13:44:44 +00:00
parent 638477cc8e
commit 86e31cfa5c
7 changed files with 540 additions and 4 deletions

View file

@ -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,
)

View file

@ -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() {
<Route path="/admin/techniques" element={<Suspense fallback={<LoadingFallback />}><AdminTechniquePages /></Suspense>} />
<Route path="/admin/users" element={<Suspense fallback={<LoadingFallback />}><AdminUsers /></Suspense>} />
<Route path="/admin/audit-log" element={<Suspense fallback={<LoadingFallback />}><AdminAuditLog /></Suspense>} />
<Route path="/admin/usage" element={<Suspense fallback={<LoadingFallback />}><AdminUsage /></Suspense>} />
{/* Info routes */}
<Route path="/about" element={<Suspense fallback={<LoadingFallback />}><About /></Suspense>} />

View file

@ -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<UsageStats> {
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();
}

View file

@ -16,3 +16,4 @@ export * from "./auth";
export * from "./creator-dashboard";
export * from "./consent";
export * from "./follows";
export * from "./admin-usage";

View file

@ -126,6 +126,14 @@ export default function AdminDropdown() {
>
Audit Log
</Link>
<Link
to="/admin/usage"
className="admin-dropdown__item"
role="menuitem"
onClick={() => setOpen(false)}
>
Usage
</Link>
</div>
)}
</div>

View file

@ -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;
}
}

View file

@ -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<UsageStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className={styles.page}>
<h1 className={styles.title}>Usage</h1>
<p className={styles.loading}>Loading usage stats</p>
</div>
);
}
if (error || !stats) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Usage</h1>
<p className={styles.error}>{error || "No data available"}</p>
</div>
);
}
const maxDaily = Math.max(...stats.daily_counts.map((d) => d.request_count), 1);
return (
<div className={styles.page}>
<h1 className={styles.title}>Usage</h1>
{/* Summary cards */}
<div className={styles.summaryGrid}>
{([
{ label: "Today", data: stats.today },
{ label: "This Week", data: stats.week },
{ label: "This Month", data: stats.month },
] as const).map(({ label, data }) => (
<div key={label} className={styles.card}>
<span className={styles.cardLabel}>{label}</span>
<span className={styles.cardValue}>
{formatNumber(data.total_tokens)} tokens
</span>
<span className={styles.cardSub}>
{data.request_count} request{data.request_count !== 1 ? "s" : ""}
{" · "}
{formatNumber(data.prompt_tokens)} prompt / {formatNumber(data.completion_tokens)} completion
</span>
</div>
))}
</div>
{/* Daily bar chart */}
{stats.daily_counts.length > 0 && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Last 7 Days</h2>
<div className={styles.chartContainer}>
{stats.daily_counts.map((d) => (
<div key={d.date} className={styles.barColumn}>
<span className={styles.barValue}>{d.request_count}</span>
<div
className={styles.bar}
style={{ height: `${(d.request_count / maxDaily) * 80}%` }}
/>
<span className={styles.barLabel}>{shortDay(d.date)}</span>
</div>
))}
</div>
</div>
)}
{/* Top creators */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Top Creators (This Month)</h2>
{stats.top_creators.length === 0 ? (
<p className={styles.empty}>No creator usage data yet.</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>Creator</th>
<th className={styles.numCell}>Requests</th>
<th className={styles.numCell}>Tokens</th>
</tr>
</thead>
<tbody>
{stats.top_creators.map((c) => (
<tr key={c.creator_slug}>
<td>{c.creator_slug}</td>
<td className={styles.numCell}>{c.request_count.toLocaleString()}</td>
<td className={styles.numCell}>{formatNumber(c.total_tokens)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Top users */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Top Users (This Month)</h2>
{stats.top_users.length === 0 ? (
<p className={styles.empty}>No user usage data yet.</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>User / IP</th>
<th className={styles.numCell}>Requests</th>
<th className={styles.numCell}>Tokens</th>
</tr>
</thead>
<tbody>
{stats.top_users.map((u, i) => (
<tr key={`${u.identifier}-${i}`}>
<td>{u.identifier}</td>
<td className={styles.numCell}>{u.request_count.toLocaleString()}</td>
<td className={styles.numCell}>{formatNumber(u.total_tokens)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}