Add period selector to all cost-bearing admin tabs
- New usePeriod hook (day/week/month/all/custom presets) with from/to ISO string outputs - New PeriodSelector component (button group + custom date inputs) - UsersTab, UsageTab, FocusGroupsTab all wired up with period state - Backend /admin/users and /admin/focus-groups now accept from/to query params - MTD Cost column header now reflects selected period label (e.g. "Cost (MTD)") - Logout clears local state only (no account sign-out) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d7ee22e557
commit
57508e8e55
9 changed files with 158 additions and 18 deletions
|
|
@ -44,11 +44,11 @@ def _month_start() -> datetime:
|
|||
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
async def _user_mtd_cost(user_id: str) -> float:
|
||||
"""Month-to-date cost for a single user."""
|
||||
async def _user_period_cost(user_id: str, from_dt: datetime, to_dt: datetime) -> float:
|
||||
"""Cost for a single user within the given period."""
|
||||
return await UsageEvent.sum_cost({
|
||||
"user_id": user_id,
|
||||
"ts": {"$gte": _month_start()},
|
||||
"ts": {"$gte": from_dt, "$lte": to_dt},
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -60,11 +60,14 @@ async def _user_mtd_cost(user_id: str) -> float:
|
|||
@jwt_required()
|
||||
@admin_required
|
||||
async def list_users():
|
||||
"""GET /api/admin/users?q=&role=&skip=&limit="""
|
||||
"""GET /api/admin/users?q=&role=&skip=&limit=&from=ISO&to=ISO"""
|
||||
q = request.args.get('q', '').strip()
|
||||
role_filter = request.args.get('role', '').strip()
|
||||
skip = max(0, int(request.args.get('skip', 0)))
|
||||
limit = min(100, max(1, int(request.args.get('limit', 50))))
|
||||
now = datetime.now(timezone.utc)
|
||||
from_dt = datetime.fromisoformat(request.args['from']) if request.args.get('from') else _month_start()
|
||||
to_dt = datetime.fromisoformat(request.args['to']) if request.args.get('to') else now
|
||||
|
||||
query = {}
|
||||
if q:
|
||||
|
|
@ -78,12 +81,11 @@ async def list_users():
|
|||
users = await User.find_all(query, skip=skip, limit=limit)
|
||||
total = await User.count(query)
|
||||
|
||||
# Attach MTD cost for each user
|
||||
result = []
|
||||
for u in users:
|
||||
user_id = str(u.get('_id', ''))
|
||||
safe = _safe_user(u)
|
||||
safe['cost_mtd'] = await _user_mtd_cost(user_id)
|
||||
safe['cost_mtd'] = await _user_period_cost(user_id, from_dt, to_dt)
|
||||
result.append(safe)
|
||||
|
||||
return jsonify({'users': result, 'total': total, 'skip': skip, 'limit': limit}), 200
|
||||
|
|
@ -440,9 +442,13 @@ async def create_pricing():
|
|||
@jwt_required()
|
||||
@admin_required
|
||||
async def list_focus_groups():
|
||||
"""GET /api/admin/focus-groups?skip=&limit= — list all focus groups with cost totals."""
|
||||
"""GET /api/admin/focus-groups?skip=&limit=&from=ISO&to=ISO — list with cost totals."""
|
||||
skip = max(0, int(request.args.get('skip', 0)))
|
||||
limit = min(200, max(1, int(request.args.get('limit', 50))))
|
||||
now = datetime.now(timezone.utc)
|
||||
from_dt = datetime.fromisoformat(request.args['from']) if request.args.get('from') else None
|
||||
to_dt = datetime.fromisoformat(request.args['to']) if request.args.get('to') else now
|
||||
|
||||
db = await get_db()
|
||||
cursor = db.focus_groups.find(
|
||||
{},
|
||||
|
|
@ -453,8 +459,11 @@ async def list_focus_groups():
|
|||
result = []
|
||||
for fg in fgs:
|
||||
fg_id = str(fg['_id'])
|
||||
cost_match: dict = {'focus_group_id': fg_id}
|
||||
if from_dt:
|
||||
cost_match['ts'] = {'$gte': from_dt, '$lte': to_dt}
|
||||
pipeline = [
|
||||
{'$match': {'focus_group_id': fg_id}},
|
||||
{'$match': cost_match},
|
||||
{'$group': {'_id': None, 'total': {'$sum': '$cost_usd.total'}, 'calls': {'$sum': 1}}},
|
||||
]
|
||||
agg = await db.usage_events.aggregate(pipeline).to_list(1)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
import { useAdminFocusGroups } from '@/hooks/useAdminFocusGroups';
|
||||
import { usePeriod } from '@/hooks/usePeriod';
|
||||
import PeriodSelector from '@/components/admin/PeriodSelector';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function FocusGroupsTab() {
|
||||
const { data, isLoading } = useAdminFocusGroups();
|
||||
const { period, setPeriod, customFrom, setCustomFrom, customTo, setCustomTo, from, to } = usePeriod('all');
|
||||
const { data, isLoading } = useAdminFocusGroups({ from, to });
|
||||
const fgs = data?.focus_groups ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PeriodSelector
|
||||
period={period} setPeriod={setPeriod}
|
||||
customFrom={customFrom} setCustomFrom={setCustomFrom}
|
||||
customTo={customTo} setCustomTo={setCustomTo}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||
) : (
|
||||
|
|
@ -20,7 +29,7 @@ export default function FocusGroupsTab() {
|
|||
<TableHead>Date</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Total Cost</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Calls</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
|
|||
64
src/components/admin/PeriodSelector.tsx
Normal file
64
src/components/admin/PeriodSelector.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { type PeriodPreset } from '@/hooks/usePeriod';
|
||||
|
||||
interface Props {
|
||||
period: PeriodPreset;
|
||||
setPeriod: (p: PeriodPreset) => void;
|
||||
customFrom: string;
|
||||
setCustomFrom: (v: string) => void;
|
||||
customTo: string;
|
||||
setCustomTo: (v: string) => void;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const PRESETS: { value: PeriodPreset; label: string }[] = [
|
||||
{ value: 'day', label: 'Today' },
|
||||
{ value: 'week', label: '7 days' },
|
||||
{ value: 'month', label: 'This month' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
|
||||
export default function PeriodSelector({
|
||||
period, setPeriod, customFrom, setCustomFrom, customTo, setCustomTo, showAll = true,
|
||||
}: Props) {
|
||||
const presets = showAll ? PRESETS : PRESETS.filter(p => p.value !== 'all');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex rounded-md border overflow-hidden text-sm">
|
||||
{presets.map(p => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
className={`px-3 py-1.5 transition-colors ${
|
||||
period === p.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-slate-50 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{period === 'custom' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="w-36 h-8 text-sm"
|
||||
value={customFrom}
|
||||
onChange={e => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">—</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-36 h-8 text-sm"
|
||||
value={customTo}
|
||||
onChange={e => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { useAdminUsageSummary, useAdminUsageEvents } from '@/hooks/useAdminUsage';
|
||||
import { usePeriod } from '@/hooks/usePeriod';
|
||||
import PeriodSelector from '@/components/admin/PeriodSelector';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
|
@ -10,9 +12,9 @@ import {
|
|||
|
||||
type GroupBy = 'user' | 'model' | 'feature' | 'day' | 'focus_group';
|
||||
|
||||
function kpiCards(totals: any) {
|
||||
function kpiCards(totals: any, periodLabel: string) {
|
||||
return [
|
||||
{ label: 'Total Cost (MTD)', value: `$${(totals?.total_cost ?? 0).toFixed(4)}`, icon: DollarSign },
|
||||
{ label: `Total Cost (${periodLabel})`, value: `$${(totals?.total_cost ?? 0).toFixed(4)}`, icon: DollarSign },
|
||||
{ label: 'LLM Calls', value: (totals?.calls ?? 0).toLocaleString(), icon: Activity },
|
||||
{ label: 'Prompt Tokens', value: ((totals?.prompt_tokens ?? 0) / 1000).toFixed(1) + 'k', icon: Zap },
|
||||
{ label: 'Completion Tokens', value: ((totals?.completion_tokens ?? 0) / 1000).toFixed(1) + 'k', icon: BarChart2 },
|
||||
|
|
@ -21,8 +23,9 @@ function kpiCards(totals: any) {
|
|||
|
||||
export default function UsageTab() {
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('user');
|
||||
const { period, setPeriod, customFrom, setCustomFrom, customTo, setCustomTo, from, to, label: periodLabel } = usePeriod('month');
|
||||
|
||||
const { data, isLoading } = useAdminUsageSummary({ group_by: groupBy });
|
||||
const { data, isLoading } = useAdminUsageSummary({ group_by: groupBy, from, to });
|
||||
const { data: eventsData, isLoading: eventsLoading } = useAdminUsageEvents({ limit: 20 });
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
|
|
@ -37,9 +40,15 @@ export default function UsageTab() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PeriodSelector
|
||||
period={period} setPeriod={setPeriod}
|
||||
customFrom={customFrom} setCustomFrom={setCustomFrom}
|
||||
customTo={customTo} setCustomTo={setCustomTo}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{kpiCards(totals).map(({ label, value, icon: Icon }) => (
|
||||
{kpiCards(totals, periodLabel).map(({ label, value, icon: Icon }) => (
|
||||
<Card key={label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-1 pt-3 px-4">
|
||||
<CardTitle className="text-xs font-medium text-slate-500">{label}</CardTitle>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { useAdminUsers, useUpdateUser, useDisableUser, useEnableUser, useCreateUser, useResetPassword } from '@/hooks/useAdminUsers';
|
||||
import { usePeriod } from '@/hooks/usePeriod';
|
||||
import PeriodSelector from '@/components/admin/PeriodSelector';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -36,7 +38,8 @@ export default function UsersTab() {
|
|||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newRole, setNewRole] = useState('user');
|
||||
|
||||
const { data, isLoading } = useAdminUsers({ q: search, role: roleFilter || undefined });
|
||||
const { period, setPeriod, customFrom, setCustomFrom, customTo, setCustomTo, from, to, label: periodLabel } = usePeriod('month');
|
||||
const { data, isLoading } = useAdminUsers({ q: search, role: roleFilter || undefined, from, to });
|
||||
const updateUser = useUpdateUser();
|
||||
const disableUser = useDisableUser();
|
||||
const enableUser = useEnableUser();
|
||||
|
|
@ -117,6 +120,13 @@ export default function UsersTab() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Period selector */}
|
||||
<PeriodSelector
|
||||
period={period} setPeriod={setPeriod}
|
||||
customFrom={customFrom} setCustomFrom={setCustomFrom}
|
||||
customTo={customTo} setCustomTo={setCustomTo}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
|
|
@ -130,7 +140,7 @@ export default function UsersTab() {
|
|||
<TableHead>User</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>MTD Cost</TableHead>
|
||||
<TableHead>Cost ({periodLabel})</TableHead>
|
||||
<TableHead>Monthly Quota</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
// Helper function to check if JWT token is expired
|
||||
const isTokenExpired = (token: string): boolean => {
|
||||
if (localStorage.getItem('offline_mode') === 'true') return false;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Date.now() / 1000;
|
||||
|
|
@ -121,6 +122,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('auth_type');
|
||||
localStorage.removeItem('offline_mode');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
|
@ -257,6 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const isAuthenticated = (() => {
|
||||
const t = token || _storedToken;
|
||||
if (!t) return false;
|
||||
if (localStorage.getItem('offline_mode') === 'true') return true;
|
||||
try {
|
||||
const payload = JSON.parse(atob(t.split('.')[1]));
|
||||
return typeof payload.exp === 'number' && payload.exp > Date.now() / 1000;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '@/lib/api';
|
||||
|
||||
export function useAdminFocusGroups(params?: { skip?: number; limit?: number }) {
|
||||
export function useAdminFocusGroups(params?: { skip?: number; limit?: number; from?: string; to?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'focus-groups', params],
|
||||
queryFn: () => adminApi.listFocusGroups(params).then(r => r.data),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { adminApi } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useAdminUsers(params?: { q?: string; role?: string; skip?: number; limit?: number }) {
|
||||
export function useAdminUsers(params?: { q?: string; role?: string; skip?: number; limit?: number; from?: string; to?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'users', params],
|
||||
queryFn: () => adminApi.listUsers(params).then(r => r.data),
|
||||
|
|
|
|||
36
src/hooks/usePeriod.ts
Normal file
36
src/hooks/usePeriod.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
|
||||
export type PeriodPreset = 'day' | 'week' | 'month' | 'all' | 'custom';
|
||||
|
||||
function startOfDay(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
export function usePeriod(defaultPreset: PeriodPreset = 'month') {
|
||||
const [period, setPeriod] = useState<PeriodPreset>(defaultPreset);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
|
||||
const { from, to } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const toStr = now.toISOString();
|
||||
if (period === 'day') return { from: startOfDay(now).toISOString(), to: toStr };
|
||||
if (period === 'week') {
|
||||
const d = new Date(now); d.setDate(d.getDate() - 7);
|
||||
return { from: startOfDay(d).toISOString(), to: toStr };
|
||||
}
|
||||
if (period === 'month') {
|
||||
return { from: new Date(now.getFullYear(), now.getMonth(), 1).toISOString(), to: toStr };
|
||||
}
|
||||
if (period === 'all') return { from: undefined as any, to: undefined as any };
|
||||
// custom
|
||||
return {
|
||||
from: customFrom ? new Date(customFrom).toISOString() : new Date(now.getFullYear(), now.getMonth(), 1).toISOString(),
|
||||
to: customTo ? new Date(customTo + 'T23:59:59').toISOString() : toStr,
|
||||
};
|
||||
}, [period, customFrom, customTo]);
|
||||
|
||||
const label = period === 'day' ? 'Today' : period === 'week' ? '7d' : period === 'month' ? 'MTD' : period === 'all' ? 'All time' : 'Custom';
|
||||
|
||||
return { period, setPeriod, customFrom, setCustomFrom, customTo, setCustomTo, from, to, label };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue