From bc4138f332ccca4098deeb34ffa2f0539df8568e Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 24 Apr 2026 18:43:13 +0100 Subject: [PATCH] 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/ (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 --- backend/app/__init__.py | 2 + backend/app/routes/ai_personas.py | 25 +++++++ backend/app/routes/focus_group_ai.py | 25 +++++++ backend/app/routes/personas.py | 6 +- backend/app/routes/usage.py | 105 +++++++++++++++++++++++++++ backend/app/services/llm_service.py | 16 ++++ src/App.tsx | 7 ++ src/components/Navigation.tsx | 7 +- src/hooks/useMyUsage.ts | 19 +++++ src/pages/FocusGroupSession.tsx | 26 ++++++- src/pages/MyUsage.tsx | 74 +++++++++++++++++++ src/services/websocketServiceNew.ts | 6 ++ 12 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 backend/app/routes/usage.py create mode 100644 src/hooks/useMyUsage.ts create mode 100644 src/pages/MyUsage.tsx diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 709003b2..dc2fbdd3 100755 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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(): diff --git a/backend/app/routes/ai_personas.py b/backend/app/routes/ai_personas.py index 19d2ff65..d8ad039f 100755 --- a/backend/app/routes/ai_personas.py +++ b/backend/app/routes/ai_personas.py @@ -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/', methods=['DELETE']) @jwt_required() +@active_required async def cleanup_customer_data(session_id): """ Clean up customer data files for a specific session. diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 751da7e7..2fbe8f6b 100755 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -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/', 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//', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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/', 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): """ diff --git a/backend/app/routes/personas.py b/backend/app/routes/personas.py index 63ce46cb..278a0dbc 100755 --- a/backend/app/routes/personas.py +++ b/backend/app/routes/personas.py @@ -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('//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('//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. diff --git a/backend/app/routes/usage.py b/backend/app/routes/usage.py new file mode 100644 index 00000000..58118434 --- /dev/null +++ b/backend/app/routes/usage.py @@ -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/', methods=['GET']) +@jwt_required() +async def focus_group_usage(fg_id: str): + """GET /api/usage/focus-groups/ — 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 diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 60d3143e..12d58931 100755 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -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) diff --git a/src/App.tsx b/src/App.tsx index 9ce0d7bb..6c4c48c5 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 */} } /> + + + + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 897b90cf..93280a09 100755 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -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 = () => { diff --git a/src/hooks/useMyUsage.ts b/src/hooks/useMyUsage.ts new file mode 100644 index 00000000..9d83c20d --- /dev/null +++ b/src/hooks/useMyUsage.ts @@ -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, + }); +} diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 4c333eac..2cc507a4 100755 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -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 = () => { + {/* Quota warning banner */} + {quotaWarning && ( +
+
+ + Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota + (${quotaWarning.used_usd.toFixed(4)} of ${quotaWarning.limit_usd.toFixed(2)}) + + +
+ +
+ )} + {/* Quota exceeded banner */} {quotaExceeded && (
diff --git a/src/pages/MyUsage.tsx b/src/pages/MyUsage.tsx new file mode 100644 index 00000000..e8c1435e --- /dev/null +++ b/src/pages/MyUsage.tsx @@ -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 ( +
+ +
+
+

My Usage

+

Month-to-date since {periodStart}

+
+ + {isLoading ? ( +
+ ) : ( +
+
+ {[ + { 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 }) => ( + + + {label} + + + +
{value}
+
+
+ ))} +
+ +
+ + + + Feature + Cost + Calls + + + + {byFeature.length === 0 && ( + + No usage data yet + + )} + {byFeature.map((row: any, i: number) => ( + + {row._id ?? '—'} + ${(row.total_cost ?? 0).toFixed(6)} + {row.calls} + + ))} + +
+
+
+ )} +
+
+ ); +} diff --git a/src/services/websocketServiceNew.ts b/src/services/websocketServiceNew.ts index f749f11f..75e2f64d 100755 --- a/src/services/websocketServiceNew.ts +++ b/src/services/websocketServiceNew.ts @@ -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;