diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 7baadff..4d0043c 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { X, Users, UserCheck, UserPlus, MessageSquare, MessagesSquare, FlaskConical, GraduationCap, BarChart3, ShieldCheck, Trash2, Plus, AlertTriangle, Layers, Download, Loader2, + Activity, RefreshCw, } from 'lucide-react'; import { supabase } from '../lib/supabase'; @@ -210,6 +211,9 @@ export function AdminDashboard({ onClose }: AdminDashboardProps) { /> + {/* Live usage (per-minute spike monitor) */} + + {/* Timeline */} @@ -307,6 +311,140 @@ function BarRow({ label, count, max }: { label: string; count: number; max: numb ); } +interface LiveUsageData { + windowMinutes: number; + series: Array<{ minute: string; calls: number; errors: number }>; + totalCalls: number; + totalErrors: number; + peakCallsPerMin: number; + generatedAt: string; +} + +/** Per-minute API-call & error volume so an organizer can watch for load spikes + * during a workshop. Auto-refreshes every 20s. Each AI turn = one provider call. */ +function LiveUsage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [auto, setAuto] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const fetchUsage = useCallback(async () => { + setRefreshing(true); + try { + const result = await callEdge({ action: 'live-usage', windowMinutes: 120 }); + setData(result as LiveUsageData); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load usage'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + fetchUsage(); + if (!auto) return; + const id = setInterval(fetchUsage, 20_000); + return () => clearInterval(id); + }, [fetchUsage, auto]); + + const series = data?.series ?? []; + const peak = Math.max(data?.peakCallsPerMin ?? 0, 1); + // Show the most recent ~90 minutes so bars stay readable. + const shown = series.slice(-90); + const fmtClock = (minute: string) => { + const d = new Date(`${minute}:00Z`); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + const updated = data?.generatedAt + ? new Date(data.generatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : null; + + return ( +
+
+

+ + Live API Usage + · per minute, last 90 min +

+
+ {updated && updated {updated}} + + +
+
+ + {loading ? ( +
+ Loading usage… +
+ ) : error ? ( +

{error}

+ ) : ( + <> +
+ } label="Peak Calls / min" value={data?.peakCallsPerMin ?? 0} sub="busiest minute" /> + } label="Calls (2h)" value={data?.totalCalls ?? 0} sub="AI turns sent" /> + } + label="Errors (2h)" + value={data?.totalErrors ?? 0} + tone={(data?.totalErrors ?? 0) > 0 ? 'warn' : 'default'} + sub="incl. rate limits" + /> +
+ + {shown.every(s => s.calls === 0 && s.errors === 0) ? ( +

No API calls in the last 90 minutes.

+ ) : ( +
+ {shown.map((s) => { + const h = Math.round((s.calls / peak) * 100); + const hasErr = s.errors > 0; + return ( +
+
+
+ ); + })} +
+ )} +
+ API calls + minute had errors (rate limit / failure) +
+ + )} +
+ ); +} + function Timeline({ data }: { data: AdminStats['timeline'] }) { const [metric, setMetric] = useState('messages'); const [hoveredIdx, setHoveredIdx] = useState(null); diff --git a/supabase/functions/workshop-config/index.ts b/supabase/functions/workshop-config/index.ts index ff480b4..d9355f9 100644 --- a/supabase/functions/workshop-config/index.ts +++ b/supabase/functions/workshop-config/index.ts @@ -459,6 +459,70 @@ Deno.serve(async (req) => { return jsonResponse({ success: true }, 200, corsHeaders); } + // === LIVE-USAGE: organizers only — per-minute API-call & error volume === + // Lets an organizer watch for load spikes during a workshop. Each assistant + // message is one provider call; provider_error events flag rate-limit/failures. + if (action === 'live-usage') { + if (!await isOrganizer(admin, user.email || '')) { + return jsonResponse({ error: 'Not authorized' }, 403, corsHeaders); + } + + const { windowMinutes } = body as { windowMinutes?: number }; + const win = Math.min(Math.max(Math.round(windowMinutes ?? 120), 10), 360); // 10min–6h + const since = new Date(Date.now() - win * 60 * 1000); + const sinceIso = since.toISOString(); + + const { data: msgs } = await admin + .from('messages') + .select('created_at, role') + .gte('created_at', sinceIso) + .limit(20000); + const { data: evs } = await admin + .from('events') + .select('created_at') + .eq('event_type', 'provider_error') + .gte('created_at', sinceIso) + .limit(20000); + + // Bucket per minute (UTC). 'YYYY-MM-DDTHH:MM' is a stable per-minute key. + const callsByMin: Record = {}; + const errorsByMin: Record = {}; + let totalCalls = 0; + let totalErrors = 0; + (msgs || []).forEach((m: { created_at: string; role: string }) => { + if (m.role === 'user') return; // count only AI turns = provider calls + const minute = (m.created_at || '').slice(0, 16); + if (!minute) return; + callsByMin[minute] = (callsByMin[minute] || 0) + 1; + totalCalls++; + }); + (evs || []).forEach((e: { created_at: string }) => { + const minute = (e.created_at || '').slice(0, 16); + if (!minute) return; + errorsByMin[minute] = (errorsByMin[minute] || 0) + 1; + totalErrors++; + }); + + // Dense, gap-filled minute series so idle minutes show as zero. + const series: Array<{ minute: string; calls: number; errors: number }> = []; + const startMs = Math.floor(since.getTime() / 60000) * 60000; + const nowMs = Math.floor(Date.now() / 60000) * 60000; + for (let t = startMs; t <= nowMs; t += 60000) { + const minute = new Date(t).toISOString().slice(0, 16); + series.push({ minute, calls: callsByMin[minute] || 0, errors: errorsByMin[minute] || 0 }); + } + const peakCallsPerMin = series.reduce((mx, s) => Math.max(mx, s.calls), 0); + + return jsonResponse({ + windowMinutes: win, + series, + totalCalls, + totalErrors, + peakCallsPerMin, + generatedAt: new Date().toISOString(), + }, 200, corsHeaders); + } + // === ADMIN-STATS: organizers only — analytics dashboard === if (action === 'admin-stats') { if (!await isOrganizer(admin, user.email || '')) {