Final pieces: decorators on LLM routes, usage self-service, billing page, WS events

Backend:
- @active_required + @with_user_context applied to all LLM-invoking routes
  in personas.py, focus_group_ai.py, ai_personas.py
- backend/app/routes/usage.py: GET /api/usage/me (MTD summary by feature),
  GET /api/usage/focus-groups/<id> (owner or admin)
- Registered usage_bp in app/__init__.py
- llm_service._record_usage now emits usage_update WS event to focus group room

Frontend:
- useMyUsage + useFocusGroupUsage hooks
- MyUsage.tsx: personal billing dashboard (cost cards + per-feature table)
- /billing route (ProtectedRoute) + Billing nav link
- FocusGroupSession: quota_warning amber banner with Progress bar,
  quota_exceeded + quota_warning WS events wired via websocketServiceNew

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-24 18:43:13 +01:00
parent 915c81b8f1
commit bc4138f332
12 changed files with 315 additions and 3 deletions

View file

@ -148,6 +148,7 @@ def create_app():
from app.routes.folders import folders_bp
from app.routes.tasks import tasks_bp
from app.routes.admin import admin_bp
from app.routes.usage import usage_bp
app.register_blueprint(auth_bp, url_prefix='/api/auth')
app.register_blueprint(personas_bp, url_prefix='/api/personas')
@ -157,6 +158,7 @@ def create_app():
app.register_blueprint(folders_bp, url_prefix='/api/folders')
app.register_blueprint(tasks_bp, url_prefix='/api/tasks')
app.register_blueprint(admin_bp, url_prefix='/api/admin')
app.register_blueprint(usage_bp, url_prefix='/api/usage')
@app.before_serving
async def start_task_sweeper():

View file

@ -21,6 +21,7 @@ from app.services.task_manager import register_cancellable_task, CancellableTask
from app.services.customer_data_service import customer_data_service, CustomerDataServiceError
from app.models.persona import Persona
from app.utils.rate_limiter import rate_limit, ip_key
from app.utils import active_required, with_user_context
# Get timeout for AI requests
AI_REQUEST_TIMEOUT = 300 # 5 minutes in seconds
@ -36,6 +37,8 @@ def _user_key():
@ai_personas_bp.route('/generate-basic-profiles', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def generate_basic_profiles():
"""
@ -115,6 +118,8 @@ async def generate_basic_profiles():
@ai_personas_bp.route('/complete-persona', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def complete_persona():
"""
@ -175,6 +180,8 @@ async def complete_persona():
@ai_personas_bp.route('/complete-and-save-persona', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def complete_and_save_persona():
"""
Second stage of the two-stage persona generation process that also saves the
@ -299,6 +306,8 @@ async def complete_and_save_persona():
@ai_personas_bp.route('/generate', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def generate_ai_persona():
"""
Generate a synthetic persona using AI and return it without saving.
@ -357,6 +366,8 @@ async def generate_ai_persona():
@ai_personas_bp.route('/generate-and-save', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def generate_and_save_persona():
"""
Generate a synthetic persona using AI and save it to the database.
@ -438,6 +449,8 @@ async def generate_and_save_persona():
@ai_personas_bp.route('/batch-generate', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def batch_generate_personas():
"""
Generate multiple synthetic personas using AI.
@ -539,6 +552,8 @@ async def batch_generate_personas():
@ai_personas_bp.route('/batch-generate-and-save', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def batch_generate_and_save_personas():
"""
Generate multiple synthetic personas using AI and save them to the database.
@ -668,6 +683,8 @@ async def batch_generate_and_save_personas():
@ai_personas_bp.route('/generate-persona-summary', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def generate_summary_for_persona():
"""
Generate an AI-synthesized summary for an existing persona.
@ -733,6 +750,8 @@ async def generate_summary_for_persona():
@ai_personas_bp.route('/enhance-audience-brief', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def enhance_audience_brief_endpoint():
"""
Enhance an audience brief and research objective using AI.
@ -801,6 +820,8 @@ async def enhance_audience_brief_endpoint():
@ai_personas_bp.route('/batch-generate-summaries', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def batch_generate_summaries():
"""
Generate comprehensive markdown summaries for multiple personas for download/client review.
@ -1170,6 +1191,8 @@ async def _run_persona_generation_bg(
@ai_personas_bp.route('/generate-personas-full', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def generate_personas_full():
"""
Async persona generation: returns task_id immediately (202),
@ -1238,6 +1261,7 @@ async def generate_personas_full():
@ai_personas_bp.route('/upload-customer-data', methods=['POST'])
@jwt_required()
@active_required
async def upload_customer_data():
"""
Upload customer data files and parse them using LlamaParse.
@ -1286,6 +1310,7 @@ async def upload_customer_data():
@ai_personas_bp.route('/cleanup-customer-data/<session_id>', methods=['DELETE'])
@jwt_required()
@active_required
async def cleanup_customer_data(session_id):
"""
Clean up customer data files for a specific session.

View file

@ -31,6 +31,7 @@ from app.services.image_description_service import ImageDescriptionService, Imag
from app.models.focus_group import FocusGroup
from app.models.persona import Persona
from app.utils.rate_limiter import rate_limit
from app.utils import active_required, with_user_context
# Create the blueprint
focus_group_ai_bp = Blueprint('focus_group_ai', __name__)
@ -42,6 +43,8 @@ def _user_key():
@focus_group_ai_bp.route('/generate-response', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def generate_ai_response():
"""
@ -250,6 +253,8 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
@focus_group_ai_bp.route('/generate-key-themes', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def generate_key_themes():
"""
@ -375,6 +380,7 @@ async def _run_key_themes_bg(app, task_id, user_id, focus_group_id, temperature)
@focus_group_ai_bp.route('/key-themes/<focus_group_id>', methods=['GET'])
@jwt_required()
@active_required
async def get_key_themes(focus_group_id):
"""
Get all generated key themes for a focus group.
@ -417,6 +423,7 @@ async def get_key_themes(focus_group_id):
@focus_group_ai_bp.route('/key-themes/<focus_group_id>/<theme_id>', methods=['DELETE'])
@jwt_required()
@active_required
async def delete_key_theme(focus_group_id, theme_id):
"""
Delete a key theme from a focus group.
@ -454,6 +461,7 @@ async def delete_key_theme(focus_group_id, theme_id):
@focus_group_ai_bp.route('/moderator/status/<focus_group_id>', methods=['GET'])
@jwt_required()
@active_required
async def get_moderator_status(focus_group_id):
"""
Get the current moderator status for a focus group.
@ -482,6 +490,8 @@ async def get_moderator_status(focus_group_id):
@focus_group_ai_bp.route('/moderator/advance/<focus_group_id>', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def advance_moderator_discussion(focus_group_id):
"""
@ -682,6 +692,7 @@ async def advance_moderator_discussion(focus_group_id):
@focus_group_ai_bp.route('/moderator/position/<focus_group_id>', methods=['PUT'])
@jwt_required()
@active_required
async def set_moderator_position(focus_group_id):
"""
Set the moderator position to a specific section and item.
@ -726,6 +737,8 @@ async def set_moderator_position(focus_group_id):
@focus_group_ai_bp.route('/autonomous/start/<focus_group_id>', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def start_autonomous_conversation(focus_group_id):
"""
@ -820,6 +833,7 @@ async def start_autonomous_conversation(focus_group_id):
@focus_group_ai_bp.route('/autonomous/stop/<focus_group_id>', methods=['POST'])
@jwt_required()
@active_required
async def stop_autonomous_conversation(focus_group_id):
"""
Stop autonomous conversation for a focus group.
@ -892,6 +906,7 @@ async def stop_autonomous_conversation(focus_group_id):
@focus_group_ai_bp.route('/autonomous/status/<focus_group_id>', methods=['GET'])
@jwt_required()
@active_required
async def get_autonomous_conversation_status(focus_group_id):
"""
Get the status of autonomous conversation for a focus group.
@ -921,6 +936,7 @@ async def get_autonomous_conversation_status(focus_group_id):
@focus_group_ai_bp.route('/conversation/state/<focus_group_id>', methods=['GET'])
@jwt_required()
@active_required
async def get_conversation_state(focus_group_id):
"""
Get the current conversation state for a focus group.
@ -953,6 +969,7 @@ async def get_conversation_state(focus_group_id):
@focus_group_ai_bp.route('/conversation/analytics/<focus_group_id>', methods=['GET'])
@jwt_required()
@active_required
async def get_conversation_analytics(focus_group_id):
"""
Get detailed conversation analytics for a focus group.
@ -985,6 +1002,8 @@ async def get_conversation_analytics(focus_group_id):
@focus_group_ai_bp.route('/conversation/decision/<focus_group_id>', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def make_conversation_decision(focus_group_id):
"""
@ -1035,6 +1054,8 @@ async def _run_conversation_decision_bg(app, task_id, user_id, focus_group_id, t
@focus_group_ai_bp.route('/conversation/insights/<focus_group_id>', methods=['GET'])
@jwt_required()
@active_required
@with_user_context
async def get_conversation_insights(focus_group_id):
"""
Get LLM-generated insights about the conversation.
@ -1060,6 +1081,7 @@ async def get_conversation_insights(focus_group_id):
@focus_group_ai_bp.route('/conversation/intervene/<focus_group_id>', methods=['POST'])
@jwt_required()
@active_required
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def manual_intervention(focus_group_id):
"""
@ -1133,6 +1155,7 @@ async def manual_intervention(focus_group_id):
@focus_group_ai_bp.route('/conversation/reasoning-history/<focus_group_id>', methods=['GET'])
@jwt_required()
@active_required
async def get_reasoning_history(focus_group_id):
"""
Get the AI reasoning history for an autonomous conversation.
@ -1163,6 +1186,8 @@ async def get_reasoning_history(focus_group_id):
@focus_group_ai_bp.route('/moderator/end-session/<focus_group_id>', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
async def end_focus_group_session(focus_group_id):
"""

View file

@ -19,7 +19,7 @@ def json_response(payload: dict, status: int = 200) -> Response:
"""Create a JSON response without async complications."""
return Response(json.dumps(payload), status=status, mimetype="application/json")
from app.utils import make_serializable
from app.utils import make_serializable, active_required, with_user_context
personas_bp = Blueprint('personas', __name__)
@ -154,6 +154,8 @@ async def create_multiple_personas():
@personas_bp.route('/<persona_id>/modify-with-ai', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def modify_persona_with_ai(persona_id):
"""
Modify a persona using AI based on natural language instructions.
@ -240,6 +242,8 @@ async def _run_modify_persona_bg(app, task_id, user_id, persona_id, modification
@personas_bp.route('/<persona_id>/export-profile', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def export_persona_profile(persona_id):
"""
Export a persona profile as beautifully formatted markdown.

105
backend/app/routes/usage.py Normal file
View file

@ -0,0 +1,105 @@
"""User self-service usage endpoints."""
import logging
from datetime import datetime, timezone
from quart import Blueprint, jsonify, request
from app.auth.quart_jwt import jwt_required, get_jwt_identity
from app.utils import active_required
from app.models.usage_event import UsageEvent
from app.db import get_db
logger = logging.getLogger(__name__)
usage_bp = Blueprint('usage', __name__)
def _month_start() -> datetime:
now = datetime.now(timezone.utc)
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
@usage_bp.route('/me', methods=['GET'])
@jwt_required()
async def my_usage():
"""GET /api/usage/me — current user's own MTD cost summary."""
user_id = get_jwt_identity()
try:
period_start = _month_start()
db = await get_db()
pipeline = [
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
{'$group': {
'_id': None,
'total_cost': {'$sum': '$cost_usd.total'},
'prompt_tokens': {'$sum': '$prompt_tokens'},
'completion_tokens': {'$sum': '$completion_tokens'},
'calls': {'$sum': 1},
}},
]
agg = await db.usage_events.aggregate(pipeline).to_list(1)
totals = agg[0] if agg else {'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0}
totals.pop('_id', None)
# By feature
feat_pipeline = [
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
{'$group': {
'_id': '$feature',
'total_cost': {'$sum': '$cost_usd.total'},
'calls': {'$sum': 1},
}},
{'$sort': {'total_cost': -1}},
]
by_feature = await db.usage_events.aggregate(feat_pipeline).to_list(20)
from app.utils import make_serializable
return jsonify({
'totals': make_serializable(totals),
'by_feature': make_serializable(by_feature),
'period_start': period_start.isoformat(),
}), 200
except Exception as e:
logger.error(f"my_usage error: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@usage_bp.route('/focus-groups/<fg_id>', methods=['GET'])
@jwt_required()
async def focus_group_usage(fg_id: str):
"""GET /api/usage/focus-groups/<id> — usage for a specific focus group (owner or admin)."""
user_id = get_jwt_identity()
try:
# Auth check — owner or admin
db = await get_db()
from bson import ObjectId
try:
fg = await db.focus_groups.find_one({'_id': ObjectId(fg_id)})
except Exception:
return jsonify({'error': 'Invalid id'}), 400
if not fg:
return jsonify({'error': 'Not found'}), 404
from app.models.user import User
user = await User.find_by_id(user_id)
is_admin = user and user.get('role') == 'admin'
is_owner = fg.get('created_by') == user_id or str(fg.get('user_id', '')) == user_id
if not is_admin and not is_owner:
return jsonify({'error': 'Forbidden'}), 403
pipeline = [
{'$match': {'focus_group_id': fg_id}},
{'$group': {
'_id': None,
'total_cost': {'$sum': '$cost_usd.total'},
'prompt_tokens': {'$sum': '$prompt_tokens'},
'completion_tokens': {'$sum': '$completion_tokens'},
'calls': {'$sum': 1},
}},
]
agg = await db.usage_events.aggregate(pipeline).to_list(1)
totals = agg[0] if agg else {'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0}
totals.pop('_id', None)
from app.utils import make_serializable
return jsonify({'totals': make_serializable(totals), 'focus_group_id': fg_id}), 200
except Exception as e:
logger.error(f"focus_group_usage error: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500

View file

@ -259,6 +259,22 @@ class LLMService:
feature=ctx.feature,
task_id=ctx.task_id,
)
# Notify focus group room of cost delta (non-fatal)
try:
if ctx.focus_group_id:
from app.models.focus_group import emit_websocket_event
asyncio.create_task(emit_websocket_event(
'usage_update',
ctx.focus_group_id,
{
'cost_delta': cost.get('total', 0),
'tokens_delta': tokens['prompt'] + tokens['completion'],
'feature': ctx.feature,
}
))
except Exception:
pass
except Exception:
logging.getLogger(__name__).warning("_record_usage failed (non-fatal)", exc_info=True)

View file

@ -12,6 +12,7 @@ import Dashboard from "./pages/Dashboard";
import PersonaProfile from "./components/persona/PersonaProfile";
import Login from "./pages/Login";
import Admin from "./pages/Admin";
import MyUsage from "./pages/MyUsage";
import ProtectedRoute from "./components/ProtectedRoute";
import AdminRoute from "./components/admin/AdminRoute";
import { AuthProvider } from "./contexts/AuthContext";
@ -82,6 +83,12 @@ const App = () => (
{/* Redirect legacy paths */}
<Route path="/old-path" element={<Navigate to="/" replace />} />
<Route path="/billing" element={
<ProtectedRoute>
<MyUsage />
</ProtectedRoute>
} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut, ShieldCheck } from 'lucide-react';
import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut, ShieldCheck, CreditCard } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
@ -32,6 +32,11 @@ export default function Navigation() {
href: '/dashboard',
icon: LayoutDashboard,
},
{
name: 'Billing',
href: '/billing',
icon: CreditCard,
},
];
const toggleMobileMenu = () => {

19
src/hooks/useMyUsage.ts Normal file
View file

@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
export function useMyUsage() {
return useQuery({
queryKey: ['usage', 'me'],
queryFn: () => api.get('/usage/me').then(r => r.data),
staleTime: 60_000,
});
}
export function useFocusGroupUsage(fgId: string | undefined) {
return useQuery({
queryKey: ['usage', 'focus-group', fgId],
queryFn: () => api.get(`/usage/focus-groups/${fgId}`).then(r => r.data),
staleTime: 60_000,
enabled: !!fgId,
});
}

View file

@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import Navigation from '@/components/Navigation';
import ParticipantPanel from '@/components/focus-group-session/ParticipantPanel';
import DiscussionPanel from '@/components/focus-group-session/DiscussionPanel';
@ -109,6 +110,7 @@ const FocusGroupSession = () => {
// Quota exceeded banner state
const [quotaExceeded, setQuotaExceeded] = useState<{ scope: string; limit_usd: number; used_usd: number } | null>(null);
const [quotaWarning, setQuotaWarning] = useState<{ scope: string; pct: number; limit_usd: number; used_usd: number } | null>(null);
// Admin-only: fetch focus group cost summary
const { data: fgCostData } = useQuery({
@ -161,7 +163,15 @@ const FocusGroupSession = () => {
setQuotaExceeded(detail);
};
window.addEventListener('quota_exceeded', handler);
return () => window.removeEventListener('quota_exceeded', handler);
const warnHandler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setQuotaWarning(detail);
};
window.addEventListener('quota_warning', warnHandler);
return () => {
window.removeEventListener('quota_exceeded', handler);
window.removeEventListener('quota_warning', warnHandler);
};
}, []);
// Initialize singleton socket (GPT-5 fix: avoid useMemo issues)
@ -1927,6 +1937,20 @@ const FocusGroupSession = () => {
</div>
</div>
{/* Quota warning banner */}
{quotaWarning && (
<div className="mx-4 mt-2 p-3 bg-amber-50 border border-amber-200 rounded-md flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<span className="text-sm text-amber-700">
Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota
(${quotaWarning.used_usd.toFixed(4)} of ${quotaWarning.limit_usd.toFixed(2)})
</span>
<Progress value={quotaWarning.pct * 100} className="w-24 h-2" />
</div>
<button className="text-xs text-amber-500 hover:text-amber-700 ml-2" onClick={() => setQuotaWarning(null)}>&#x2715;</button>
</div>
)}
{/* Quota exceeded banner */}
{quotaExceeded && (
<div className="mx-0 mt-2 mb-2 p-3 bg-red-50 border border-red-200 rounded-md flex items-center justify-between">

74
src/pages/MyUsage.tsx Normal file
View file

@ -0,0 +1,74 @@
import { useMyUsage } from '@/hooks/useMyUsage';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, DollarSign, Zap, Activity } from 'lucide-react';
import Navigation from '@/components/Navigation';
export default function MyUsage() {
const { data, isLoading } = useMyUsage();
const totals = data?.totals ?? {};
const byFeature: any[] = data?.by_feature ?? [];
const periodStart = data?.period_start ? new Date(data.period_start).toLocaleDateString() : '—';
return (
<div className="min-h-screen bg-background">
<Navigation />
<div className="max-w-4xl mx-auto px-4 py-24">
<div className="mb-6">
<h1 className="text-2xl font-bold">My Usage</h1>
<p className="text-sm text-slate-500 mt-1">Month-to-date since {periodStart}</p>
</div>
{isLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{[
{ label: 'Total Cost (MTD)', value: `$${(totals.total_cost ?? 0).toFixed(4)}`, icon: DollarSign },
{ label: 'LLM Calls', value: (totals.calls ?? 0).toLocaleString(), icon: Activity },
{ label: 'Total Tokens', value: (((totals.prompt_tokens ?? 0) + (totals.completion_tokens ?? 0)) / 1000).toFixed(1) + 'k', icon: Zap },
].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>
<Icon className="h-4 w-4 text-slate-400" />
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="text-xl font-bold">{value}</div>
</CardContent>
</Card>
))}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Feature</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Calls</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byFeature.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center text-slate-500 py-8">No usage data yet</TableCell>
</TableRow>
)}
{byFeature.map((row: any, i: number) => (
<TableRow key={i}>
<TableCell className="text-sm">{row._id ?? '—'}</TableCell>
<TableCell className="text-right font-mono text-sm">${(row.total_cost ?? 0).toFixed(6)}</TableCell>
<TableCell className="text-right text-sm">{row.calls}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -82,6 +82,12 @@ export function initSocket(getToken: () => string): Socket {
case 'bulk_export_progress':
window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload }));
break;
case 'quota_warning':
window.dispatchEvent(new CustomEvent("quota_warning", { detail: payload }));
break;
case 'quota_exceeded':
window.dispatchEvent(new CustomEvent("quota_exceeded", { detail: payload }));
break;
case 'error':
console.error('[WebSocket] Error:', payload);
break;