diff --git a/database/add_pipeline_analytics_view.sql b/database/add_pipeline_analytics_view.sql new file mode 100644 index 0000000..50f5be2 --- /dev/null +++ b/database/add_pipeline_analytics_view.sql @@ -0,0 +1,83 @@ +-- Pipeline Analytics: Materialized view for efficient latency aggregation +-- Run this in Supabase SQL Editor after setup-supabase.sql +-- +-- This view pre-aggregates per-turn pipeline latency metrics (STT, LLM TTFT, +-- TTS TTFB, EOU delay) from soundflare_metrics_logs so the analytics dashboard +-- can query summaries without scanning the full table every time. + +-- 1. Create materialized view +CREATE MATERIALIZED VIEW IF NOT EXISTS public.pipeline_analytics_daily AS +SELECT + ml.session_id, + cl.agent_id, + DATE(cl.call_started_at) AS call_date, + EXTRACT(HOUR FROM cl.call_started_at) AS call_hour, + + -- STT duration (seconds) + AVG((ml.stt_metrics->>'duration')::numeric) + FILTER (WHERE ml.stt_metrics->>'duration' IS NOT NULL) + AS avg_stt_duration, + + -- LLM time-to-first-token (seconds) + AVG((ml.llm_metrics->>'ttft')::numeric) + FILTER (WHERE ml.llm_metrics->>'ttft' IS NOT NULL) + AS avg_llm_ttft, + + -- TTS time-to-first-byte (seconds) + AVG((ml.tts_metrics->>'ttfb')::numeric) + FILTER (WHERE ml.tts_metrics->>'ttfb' IS NOT NULL) + AS avg_tts_ttfb, + + -- End-of-utterance delay (seconds) + AVG((ml.eou_metrics->>'end_of_utterance_delay')::numeric) + FILTER (WHERE ml.eou_metrics->>'end_of_utterance_delay' IS NOT NULL) + AS avg_eou_delay, + + -- Turn count for weighting + COUNT(*) AS turn_count + +FROM public.soundflare_metrics_logs ml +JOIN public.soundflare_call_logs cl + ON ml.session_id = cl.id +WHERE cl.call_started_at IS NOT NULL +GROUP BY ml.session_id, cl.agent_id, DATE(cl.call_started_at), EXTRACT(HOUR FROM cl.call_started_at); + +-- 2. Unique index (required for CONCURRENTLY refresh) +CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_analytics_unique + ON public.pipeline_analytics_daily (session_id, call_date, call_hour); + +-- 3. Lookup index for fast agent + date queries +CREATE INDEX IF NOT EXISTS idx_pipeline_analytics_agent_date + ON public.pipeline_analytics_daily (agent_id, call_date); + +-- 3. Refresh function (call from cron or API) +CREATE OR REPLACE FUNCTION public.refresh_pipeline_analytics() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY public.pipeline_analytics_daily; +END; +$$; + +-- 4. P95 helper — returns the 95th-percentile latency for a date range +CREATE OR REPLACE FUNCTION public.pipeline_p95_latency( + p_agent_id uuid, + p_from date, + p_to date +) +RETURNS numeric +LANGUAGE sql +STABLE +AS $$ + SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY cl.avg_latency) + FROM public.soundflare_call_logs cl + WHERE cl.agent_id = p_agent_id + AND cl.call_started_at >= p_from + AND cl.call_started_at < (p_to + INTERVAL '1 day') + AND cl.avg_latency IS NOT NULL; +$$; + +-- 5. RLS policy (mirrors existing call_logs policy) +ALTER MATERIALIZED VIEW public.pipeline_analytics_daily OWNER TO postgres; diff --git a/src/app/[projectid]/analytics/page.tsx b/src/app/[projectid]/analytics/page.tsx new file mode 100644 index 0000000..2d63308 --- /dev/null +++ b/src/app/[projectid]/analytics/page.tsx @@ -0,0 +1,82 @@ +// src/app/[projectid]/analytics/page.tsx +'use client' + +import { useParams, useSearchParams } from 'next/navigation' +import { Suspense } from 'react' +import { PipelineAnalytics } from '@/components/analytics/PipelineAnalytics' +import { Skeleton } from '@/components/ui/skeleton' +import { Card, CardContent } from '@/components/ui/card' + +function AnalyticsSkeleton() { + return ( +
+
+ + +
+
+ {[1, 2, 3, 4].map((i) => ( + + +
+ +
+ + +
+
+
+
+ ))} +
+
+ + +
+
+ ) +} + +function AnalyticsContent() { + const params = useParams() + const searchParams = useSearchParams() + + const projectId = Array.isArray(params?.projectid) + ? params.projectid[0] + : (params?.projectid as string) + + // Agent can be specified via query param: ?agent_id=xxx + const agentId = searchParams.get('agent_id') || '' + + if (!agentId) { + return ( +
+
+

Select an Agent

+

+ Navigate to an agent's page and click "Pipeline Analytics" to view + latency and performance metrics. +

+

+ Or append ?agent_id=YOUR_AGENT_ID to + this URL. +

+
+
+ ) + } + + return ( +
+ +
+ ) +} + +export default function AnalyticsPage() { + return ( + }> + + + ) +} diff --git a/src/app/api/analytics/pipeline/route.ts b/src/app/api/analytics/pipeline/route.ts new file mode 100644 index 0000000..25e8e0d --- /dev/null +++ b/src/app/api/analytics/pipeline/route.ts @@ -0,0 +1,266 @@ +// app/api/analytics/pipeline/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getSupabaseAdmin } from '@/lib/supabase-server' + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const MAX_CALLS_PER_PAGE = 100 +const SUPABASE_IN_BATCH = 200 // Supabase .in() safe limit + +function isValidUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s) +} + +function maskPhone(phone: string | null): string { + if (!phone) return '' + if (phone.length > 7) return `***${phone.slice(-4)}` + return phone +} + +/** + * GET /api/analytics/pipeline?agent_id=&from=&to=&page=&limit= + * + * Returns pipeline latency analytics: + * - Aggregate KPIs (avg latency, P95, STT/LLM/TTS/EOU averages) + * - Daily call volumes + * - Hourly latency trend + * - Per-call latency breakdown for the call log table (paginated) + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const agentId = searchParams.get('agent_id') + const from = searchParams.get('from') + const to = searchParams.get('to') + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1) + const limit = Math.min( + MAX_CALLS_PER_PAGE, + Math.max(1, parseInt(searchParams.get('limit') || '50', 10) || 50) + ) + + if (!agentId || !isValidUUID(agentId)) { + return NextResponse.json( + { error: 'A valid agent_id (UUID) is required' }, + { status: 400 } + ) + } + + // Validate date format + const dateTo = to && DATE_RE.test(to) ? to : new Date().toISOString().split('T')[0] + const dateFrom = + from && DATE_RE.test(from) + ? from + : new Date(Date.now() - 7 * 86400000).toISOString().split('T')[0] + + const supabase = getSupabaseAdmin() + + // 1. Fetch all call logs for the period (for aggregate KPIs + charts) + const { data: callLogs, error: callError } = await supabase + .from('soundflare_call_logs') + .select( + 'id, avg_latency, duration_seconds, call_ended_reason, call_started_at, customer_number' + ) + .eq('agent_id', agentId) + .gte('call_started_at', `${dateFrom} 00:00:00`) + .lte('call_started_at', `${dateTo} 23:59:59.999`) + .order('call_started_at', { ascending: false }) + + if (callError) throw callError + + const logs = callLogs || [] + const totalCalls = logs.length + + // 2. Paginate calls for the table + const offset = (page - 1) * limit + const paginatedCalls = logs.slice(offset, offset + limit) + const paginatedIds = paginatedCalls.map((c) => c.id) + + // 3. Fetch turn-level metrics only for the current page (batched .in()) + let metricsLogs: any[] = [] + if (paginatedIds.length > 0) { + for (let i = 0; i < paginatedIds.length; i += SUPABASE_IN_BATCH) { + const batch = paginatedIds.slice(i, i + SUPABASE_IN_BATCH) + const { data: ml, error: mlError } = await supabase + .from('soundflare_metrics_logs') + .select( + 'session_id, turn_id, stt_metrics, llm_metrics, tts_metrics, eou_metrics, user_transcript, agent_response, created_at' + ) + .in('session_id', batch) + .order('created_at', { ascending: true }) + + if (mlError) throw mlError + if (ml) metricsLogs.push(...ml) + } + } + + // 4. Also fetch turn metrics for ALL calls (summary-only fields) + // but only the numeric columns needed for averages + let allTurnMetrics: any[] = [] + const allCallIds = logs.map((c) => c.id) + for (let i = 0; i < allCallIds.length; i += SUPABASE_IN_BATCH) { + const batch = allCallIds.slice(i, i + SUPABASE_IN_BATCH) + const { data: ml, error: mlError } = await supabase + .from('soundflare_metrics_logs') + .select('stt_metrics, llm_metrics, tts_metrics, eou_metrics') + .in('session_id', batch) + + if (mlError) throw mlError + if (ml) allTurnMetrics.push(...ml) + } + + // --- Compute aggregates from ALL logs --- + const successCalls = logs.filter( + (c) => + c.call_ended_reason === 'customer_ended_call' || + c.call_ended_reason === 'agent_ended_call' + ).length + const totalMinutes = + logs.reduce((s, c) => s + (c.duration_seconds || 0), 0) / 60 + const latencies = logs + .map((c) => c.avg_latency) + .filter((l): l is number => l != null && l > 0) + const avgLatency = + latencies.length > 0 + ? latencies.reduce((a, b) => a + b, 0) / latencies.length + : null + const p95Latency = + latencies.length > 0 + ? latencies.sort((a, b) => a - b)[Math.floor(latencies.length * 0.95)] + : null + const successRate = + totalCalls > 0 ? Math.round((successCalls / totalCalls) * 100) : 0 + + // Pipeline averages from ALL turn-level data + const sttDurations: number[] = [] + const llmTtfts: number[] = [] + const ttsTtfbs: number[] = [] + const eouDelays: number[] = [] + + for (const m of allTurnMetrics) { + const stt = m.stt_metrics?.duration + const llm = m.llm_metrics?.ttft + const tts = m.tts_metrics?.ttfb + const eou = m.eou_metrics?.end_of_utterance_delay + if (typeof stt === 'number' && isFinite(stt)) sttDurations.push(stt) + if (typeof llm === 'number' && isFinite(llm)) llmTtfts.push(llm) + if (typeof tts === 'number' && isFinite(tts)) ttsTtfbs.push(tts) + if (typeof eou === 'number' && isFinite(eou)) eouDelays.push(eou) + } + + const avg = (arr: number[]) => + arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : null + + const avgStt = avg(sttDurations) + const avgLlm = avg(llmTtfts) + const avgTts = avg(ttsTtfbs) + const estimatedTts = + avgTts != null + ? avgTts + : avgLatency != null && avgStt != null && avgLlm != null + ? Math.max(0, avgLatency - avgStt - avgLlm) + : null + + // --- Daily volumes --- + const dailyMap = new Map() + for (const c of logs) { + const day = c.call_started_at?.split('T')[0] || 'unknown' + const entry = dailyMap.get(day) || { calls: 0, minutes: 0 } + entry.calls++ + entry.minutes += (c.duration_seconds || 0) / 60 + dailyMap.set(day, entry) + } + const dailyVolumes = Array.from(dailyMap.entries()) + .map(([date, v]) => ({ + date, + calls: v.calls, + minutes: Math.round(v.minutes * 10) / 10, + })) + .sort((a, b) => a.date.localeCompare(b.date)) + + // --- Hourly latency --- + const hourlyMap = new Map() + for (const c of logs) { + if (c.avg_latency == null) continue + const hour = new Date(c.call_started_at).getHours() + const entry = hourlyMap.get(hour) || { totalLat: 0, count: 0 } + entry.totalLat += c.avg_latency + entry.count++ + hourlyMap.set(hour, entry) + } + const hourlyLatency = Array.from(hourlyMap.entries()) + .map(([hour, v]) => ({ + hour: `${String(hour).padStart(2, '0')}:00`, + avg_latency: + v.count > 0 + ? Math.round((v.totalLat / v.count) * 1000) / 1000 + : 0, + })) + .sort((a, b) => a.hour.localeCompare(b.hour)) + + // --- Per-call breakdown (paginated, phones masked) --- + const turnsBySession = new Map() + for (const m of metricsLogs) { + const sid = m.session_id + if (!turnsBySession.has(sid)) turnsBySession.set(sid, []) + turnsBySession.get(sid)!.push(m) + } + + const callBreakdown = paginatedCalls.map((c) => ({ + id: c.id, + started_at: c.call_started_at, + duration_seconds: c.duration_seconds, + avg_latency: c.avg_latency, + ended_reason: c.call_ended_reason, + customer_number: maskPhone(c.customer_number), + turn_count: turnsBySession.get(c.id)?.length || 0, + turns: (turnsBySession.get(c.id) || []).map((t) => ({ + turn_id: t.turn_id, + user_transcript: t.user_transcript, + agent_response: t.agent_response, + stt_duration: t.stt_metrics?.duration ?? null, + llm_ttft: t.llm_metrics?.ttft ?? null, + tts_ttfb: t.tts_metrics?.ttfb ?? null, + eou_delay: t.eou_metrics?.end_of_utterance_delay ?? null, + created_at: t.created_at, + })), + })) + + return NextResponse.json({ + summary: { + total_calls: totalCalls, + total_minutes: Math.round(totalMinutes * 10) / 10, + success_count: successCalls, + failed_count: totalCalls - successCalls, + avg_latency: + avgLatency != null ? Math.round(avgLatency * 1000) / 1000 : null, + p95_latency: + p95Latency != null ? Math.round(p95Latency * 1000) / 1000 : null, + success_rate: successRate, + avg_stt_duration: avgStt != null ? Math.round(avgStt * 10000) / 10000 : null, + avg_llm_ttft: avgLlm != null ? Math.round(avgLlm * 10000) / 10000 : null, + avg_tts_ttfb: avgTts != null ? Math.round(avgTts * 10000) / 10000 : null, + avg_eou_delay: avg(eouDelays) != null ? Math.round(avg(eouDelays)! * 10000) / 10000 : null, + estimated_tts: + estimatedTts != null + ? Math.round(estimatedTts * 10000) / 10000 + : null, + turn_count: allTurnMetrics.length, + }, + daily_volumes: dailyVolumes, + hourly_latency: hourlyLatency, + calls: callBreakdown, + pagination: { + page, + limit, + total: totalCalls, + total_pages: Math.ceil(totalCalls / limit), + }, + date_range: { from: dateFrom, to: dateTo }, + }) + } catch (err: any) { + console.error('[pipeline-analytics]', err) + return NextResponse.json( + { error: err.message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/components/analytics/PipelineAnalytics.tsx b/src/components/analytics/PipelineAnalytics.tsx new file mode 100644 index 0000000..44c9323 --- /dev/null +++ b/src/components/analytics/PipelineAnalytics.tsx @@ -0,0 +1,864 @@ +// components/analytics/PipelineAnalytics.tsx +'use client' + +import React, { useState, useMemo, useEffect, useCallback } from 'react' +import { + Phone, + Clock, + CheckCircle, + Lightning, + Microphone, + Brain, + SpeakerHigh, + Waveform, + ChartBar, + CaretDown, + CaretUp, + CaretLeft, + CaretRight, + X, +} from 'phosphor-react' +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Area, + AreaChart, +} from 'recharts' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + usePipelineAnalytics, + type CallBreakdown, + type TurnBreakdown, +} from '@/hooks/usePipelineAnalytics' +import { useMobile } from '@/hooks/use-mobile' + +// ─── Helpers ──────────────────────────────────────────────────── + +function fmtMs(seconds: number | null | undefined): string { + if (seconds == null) return '—' + return `${(seconds * 1000).toFixed(0)}ms` +} + +function fmtLatency(seconds: number | null | undefined): string { + if (seconds == null) return '—' + return seconds >= 1 ? `${seconds.toFixed(2)}s` : `${(seconds * 1000).toFixed(0)}ms` +} + +function fmtDuration(seconds: number | null | undefined): string { + if (seconds == null) return '—' + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + return m > 0 ? `${m}m ${s}s` : `${s}s` +} + +function fmtPhone(phone: string): string { + if (!phone) return '—' + if (phone.length > 7) return `***${phone.slice(-4)}` + return phone +} + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }) +} + +// ─── Date Range Selector ──────────────────────────────────────── + +const DATE_RANGES = [ + { label: 'Last 24h', value: '1d' }, + { label: 'Last 7 days', value: '7d' }, + { label: 'Last 30 days', value: '30d' }, + { label: 'Last 90 days', value: '90d' }, +] as const + +function getDateRange(period: string): { from: string; to: string } { + const to = new Date() + const from = new Date() + const days = period === '1d' ? 1 : period === '7d' ? 7 : period === '30d' ? 30 : 90 + from.setDate(from.getDate() - days) + return { + from: from.toISOString().split('T')[0], + to: to.toISOString().split('T')[0], + } +} + +function PeriodSelector({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) { + return ( +
+ {DATE_RANGES.map((r) => ( + + ))} +
+ ) +} + +// ─── KPI Card ─────────────────────────────────────────────────── + +function KpiCard({ + title, + value, + subtitle, + icon: Icon, + iconClassName, +}: { + title: string + value: string | number + subtitle?: string + icon: React.ElementType + iconClassName?: string +}) { + return ( + + +
+
+ +
+
+

+ {title} +

+

{value}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+
+
+ ) +} + +// ─── Pipeline Latency Card ────────────────────────────────────── + +function PipelineCard({ + title, + value, + subtitle, + icon: Icon, + color, +}: { + title: string + value: string + subtitle: string + icon: React.ElementType + color: string +}) { + const colorMap: Record = { + blue: 'text-blue-500', + purple: 'text-purple-500', + emerald: 'text-emerald-500', + amber: 'text-amber-500', + } + + return ( + + +
+ + + {title} + +
+

{value}

+

{subtitle}

+
+
+ ) +} + +// ─── Call Detail Slide-over ───────────────────────────────────── + +function CallDetailPanel({ + call, + onClose, +}: { + call: CallBreakdown + onClose: () => void +}) { + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [onClose]) + + return ( +
+ + ) +} + +function TurnCard({ + turn, + callAvgLatency, +}: { + turn: TurnBreakdown + callAvgLatency: number | null +}) { + const hasMetrics = turn.stt_duration != null || turn.llm_ttft != null + // Estimate TTS when direct measurement isn't available + const estTts = + turn.stt_duration != null && turn.llm_ttft != null && callAvgLatency != null + ? Math.max(0, callAvgLatency - turn.stt_duration - turn.llm_ttft) + : null + + return ( +
+
+ + {turn.turn_id.replace(/_/g, ' ').toUpperCase()} + + + {fmtTime(turn.created_at)} + +
+ + {turn.user_transcript && ( +

+ User: {turn.user_transcript} +

+ )} + {turn.agent_response && ( +

+ Agent:{' '} + {turn.agent_response.length > 120 + ? turn.agent_response.slice(0, 120) + '…' + : turn.agent_response} +

+ )} + + {hasMetrics && ( +
+ {turn.stt_duration != null && ( + + )} + {turn.llm_ttft != null && ( + + )} + {turn.eou_delay != null && ( + + )} + {turn.tts_ttfb != null && ( + + )} + {turn.tts_ttfb == null && estTts != null && estTts > 0 && ( + + )} +
+ )} +
+ ) +} + +function MetricBadge({ + icon: Icon, + label, + value, + color, +}: { + icon: React.ElementType + label: string + value: string + color: string +}) { + return ( +
+ + {label}: + {value} +
+ ) +} + +// ─── Chart Tooltip ────────────────────────────────────────────── + +function ChartTooltipContent({ active, payload, label }: any) { + if (!active || !payload?.length) return null + return ( +
+

{label}

+ {payload.map((p: any) => ( +

+ + {p.name}: + + {typeof p.value === 'number' ? p.value.toFixed(2) : p.value} + +

+ ))} +
+ ) +} + +// ─── Skeleton Loaders ─────────────────────────────────────────── + +function KpiSkeleton() { + return ( + + +
+ +
+ + +
+
+
+
+ ) +} + +function ChartSkeleton() { + return ( + + + + + + + ) +} + +// ─── Main Component ───────────────────────────────────────────── + +interface PipelineAnalyticsProps { + agentId: string + agentName?: string +} + +export function PipelineAnalytics({ agentId, agentName }: PipelineAnalyticsProps) { + const [period, setPeriod] = useState('7d') + const [callPage, setCallPage] = useState(1) + const [selectedCall, setSelectedCall] = useState(null) + const [expandedCallId, setExpandedCallId] = useState(null) + const { isMobile } = useMobile() + + const { from, to } = useMemo(() => getDateRange(period), [period]) + + // Reset page when period changes + const handlePeriodChange = useCallback((p: string) => { + setPeriod(p) + setCallPage(1) + setExpandedCallId(null) + }, []) + + const { data, isLoading, isError } = usePipelineAnalytics({ + agentId, + from, + to, + page: callPage, + }) + + const summary = data?.summary + const dailyVolumes = data?.daily_volumes || [] + const hourlyLatency = data?.hourly_latency || [] + const calls = data?.calls || [] + const pagination = data?.pagination + + return ( +
+ {/* Header */} +
+
+

+ Pipeline Analytics +

+

+ + Voice agent performance & latency metrics + {agentName && ( + — {agentName} + )} +

+
+ +
+ + {/* Error banner */} + {isError && ( +
+

+ Failed to load analytics data. Please refresh the page. +

+
+ )} + + {/* KPI Cards */} + {isLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : ( +
+ + + + +
+ )} + + {/* Pipeline Latency Breakdown */} + {!isLoading && summary && ( +
+ + + + +
+ )} + + {/* Charts */} +
+ {isLoading ? ( + <> + + + + ) : ( + <> + {/* Daily Call Volume */} + + + + + Daily Call Volume + + + + {dailyVolumes.length > 0 ? ( + + + + fmtDate(d)} + className="text-xs" + tick={{ fontSize: 11 }} + /> + + } /> + + + + ) : ( +
+ No call data for this period +
+ )} +
+
+ + {/* Hourly Latency Trend */} + + + + + Hourly Latency Trend + + + + {hourlyLatency.length > 0 ? ( + + + + + + + + + + + `${v.toFixed(1)}s`} + tick={{ fontSize: 11 }} + /> + } /> + + + + ) : ( +
+ No latency data for this period +
+ )} +
+
+ + )} +
+ + {/* Call Log Table */} + {!isLoading && ( + + + + + Call Log ({calls.length}) + + + +
+ + + + + + + + + + + + + {calls.map((call) => ( + + + setExpandedCallId( + expandedCallId === call.id ? null : call.id + ) + } + > + + + + + + + + + {/* Expanded turn metrics */} + {expandedCallId === call.id && call.turns.length > 0 && ( + + + + )} + + ))} + {calls.length === 0 && ( + + + + )} + +
TimePhoneDurationAvg LatencyTurnsStatus +
+ {fmtDate(call.started_at)}{' '} + + {fmtTime(call.started_at)} + + + {fmtPhone(call.customer_number)} + + {fmtDuration(call.duration_seconds)} + + {fmtLatency(call.avg_latency)} + {call.turn_count} + + {call.ended_reason?.replace(/_/g, ' ') || 'unknown'} + + +
+ + {expandedCallId === call.id ? ( + + ) : ( + + )} +
+
+
+ {call.turns.map((turn) => ( +
+ + {turn.turn_id} + + {turn.stt_duration != null && ( + + + {fmtMs(turn.stt_duration)} + + )} + {turn.llm_ttft != null && ( + + + {fmtMs(turn.llm_ttft)} + + )} + {turn.tts_ttfb != null && ( + + + {fmtMs(turn.tts_ttfb)} + + )} + {turn.eou_delay != null && ( + + + {fmtMs(turn.eou_delay)} + + )} + + {turn.user_transcript?.slice(0, 60)} + +
+ ))} +
+
+ No calls found for this period +
+
+ + {/* Pagination */} + {pagination && pagination.total_pages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.limit + 1}– + {Math.min(pagination.page * pagination.limit, pagination.total)} of{' '} + {pagination.total} calls +

+
+ + + {callPage} / {pagination.total_pages} + + +
+
+ )} +
+
+ )} + + {/* Call Detail Slide-over */} + {selectedCall && ( + setSelectedCall(null)} + /> + )} +
+ ) +} diff --git a/src/components/analytics/index.ts b/src/components/analytics/index.ts new file mode 100644 index 0000000..90adf64 --- /dev/null +++ b/src/components/analytics/index.ts @@ -0,0 +1 @@ +export { PipelineAnalytics } from './PipelineAnalytics' diff --git a/src/components/shared/SidebarWrapper.tsx b/src/components/shared/SidebarWrapper.tsx index 84be87a..e9c2a18 100644 --- a/src/components/shared/SidebarWrapper.tsx +++ b/src/components/shared/SidebarWrapper.tsx @@ -243,6 +243,7 @@ const sidebarRoutes: SidebarRoute[] = [ { pattern: '/:projectId/agents/:agentId/phone-call-config' }, { pattern: '/:projectId/agents/:agentId/evaluations' }, { pattern: '/:projectId/agents/:agentId/evaluations/:evaluationId' }, + { pattern: '/:projectId/analytics' }, ], getSidebarConfig: (params, context) => { const { projectId, agentId } = params @@ -286,6 +287,13 @@ const sidebarRoutes: SidebarRoute[] = [ icon: 'BarChart', path: `/${projectId}/agents/${agentId}/evaluations`, group: 'Logs' + }, + { + id: 'pipeline-analytics', + name: 'Pipeline Analytics', + icon: 'Activity', + path: `/${projectId}/analytics?agent_id=${agentId}`, + group: 'Logs' } ] diff --git a/src/hooks/usePipelineAnalytics.ts b/src/hooks/usePipelineAnalytics.ts new file mode 100644 index 0000000..452f1d9 --- /dev/null +++ b/src/hooks/usePipelineAnalytics.ts @@ -0,0 +1,109 @@ +// hooks/usePipelineAnalytics.ts +'use client' + +import { useQuery } from '@tanstack/react-query' + +export interface PipelineSummary { + total_calls: number + total_minutes: number + success_count: number + failed_count: number + avg_latency: number | null + p95_latency: number | null + success_rate: number + avg_stt_duration: number | null + avg_llm_ttft: number | null + avg_tts_ttfb: number | null + avg_eou_delay: number | null + estimated_tts: number | null + turn_count: number +} + +export interface DailyVolume { + date: string + calls: number + minutes: number +} + +export interface HourlyLatency { + hour: string + avg_latency: number +} + +export interface TurnBreakdown { + turn_id: string + user_transcript: string | null + agent_response: string | null + stt_duration: number | null + llm_ttft: number | null + tts_ttfb: number | null + eou_delay: number | null + created_at: string +} + +export interface CallBreakdown { + id: string + started_at: string + duration_seconds: number + avg_latency: number | null + ended_reason: string + customer_number: string + turn_count: number + turns: TurnBreakdown[] +} + +export interface Pagination { + page: number + limit: number + total: number + total_pages: number +} + +export interface PipelineAnalyticsData { + summary: PipelineSummary + daily_volumes: DailyVolume[] + hourly_latency: HourlyLatency[] + calls: CallBreakdown[] + pagination: Pagination + date_range: { from: string; to: string } +} + +interface UsePipelineAnalyticsProps { + agentId: string | undefined + from: string + to: string + page?: number + limit?: number + enabled?: boolean +} + +export const usePipelineAnalytics = ({ + agentId, + from, + to, + page = 1, + limit = 50, + enabled = true, +}: UsePipelineAnalyticsProps) => { + return useQuery({ + queryKey: ['pipeline-analytics', agentId, from, to, page, limit], + queryFn: async () => { + const params = new URLSearchParams({ + agent_id: agentId!, + from, + to, + page: String(page), + limit: String(limit), + }) + const res = await fetch(`/api/analytics/pipeline?${params}`) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `HTTP ${res.status}`) + } + return res.json() + }, + enabled: enabled && !!agentId, + staleTime: 60_000, + refetchInterval: 120_000, + }) +}