diff --git a/frontend/src/app/(dashboard)/prompt-library/[id]/page.tsx b/frontend/src/app/(dashboard)/prompt-library/[id]/page.tsx index 4e63ecf..d0b8009 100644 --- a/frontend/src/app/(dashboard)/prompt-library/[id]/page.tsx +++ b/frontend/src/app/(dashboard)/prompt-library/[id]/page.tsx @@ -72,7 +72,7 @@ export default function PromptLibraryDetailPage({ const handleUnstar = async () => { if (!window.confirm('Remove this prompt from your library?')) return; try { - await unlikeMutation.mutateAsync(params.id); + await unlikeMutation.mutateAsync({ id: params.id, promptVersionId: data?.prompt_version_id }); router.push('/prompt-library'); } catch { toast.error('Failed to remove from library'); diff --git a/frontend/src/components/admin/analytics/developer-metrics.tsx b/frontend/src/components/admin/analytics/developer-metrics.tsx new file mode 100644 index 0000000..c8e269d --- /dev/null +++ b/frontend/src/components/admin/analytics/developer-metrics.tsx @@ -0,0 +1,697 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import type { AnalyticsResponse, AnalyticsPoint } from '@/types/analytics'; +import { getSeries } from '@/types/analytics'; +import { MetricCard } from './metric-card'; +import { StaticCard } from './static-card'; +import { IssuesTable } from './issues-table'; +import { ReleasesCard } from './releases-card'; +import { IssueDetailPanel } from './issue-detail-panel'; +import type { SentryIssue, SentryRelease, EndpointLatency } from '@/types/analytics'; + +// ── Colors ──────────────────────────────────────────────────────────────────── + +const STATUS_COLORS: Record = { + completed: '#10b981', + failed: '#f43f5e', + queued: '#f59e0b', + calibrating: '#06b6d4', + extracting_mapping: '#8b5cf6', + adapting: '#3b82f6', + cancelled: '#6b7280', +}; + +const SESSION_COLORS: Record = { + healthy: '#10b981', + crashed: '#f43f5e', + errored: '#f59e0b', + abnormal: '#6b7280', + unhandled: '#f97316', +}; + +const LEVEL_COLORS: Record = { + error: '#f43f5e', + warning: '#f59e0b', + info: '#06b6d4', + debug: '#6b7280', +}; + +// ── Section divider ─────────────────────────────────────────────────────────── + +function SectionHeader({ title }: { title: string }) { + return ( +
+ + {title} + +
+
+ ); +} + +// ── Distribution card (categorical labels, no date parsing) ────────────────── + +interface DistItem { + label: string; + value: number; + color: string; +} + +function DistributionCard({ + title, items, subtitle, +}: { + title: string; + items: DistItem[]; + subtitle?: string; +}) { + const total = items.reduce((s, i) => s + i.value, 0); + return ( +
+
+ + {title} + + {subtitle && ( + {subtitle} + )} +
+ + {items.length === 0 ? ( + No data yet + ) : ( +
+ {items.map(item => { + const pct = total > 0 ? (item.value / total) * 100 : 0; + return ( +
+
+
+
+ + {item.label} + +
+
+ + {item.value.toLocaleString()} + + + {pct.toFixed(1)}% + +
+
+
+
+
+
+ ); + })} +
+ )} +
+ ); +} + +function buildStatusItems(points: AnalyticsPoint[]): DistItem[] { + return points.map(p => ({ + label: p.date.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), + value: p.value, + color: STATUS_COLORS[p.date] ?? '#6b7280', + })); +} + +// ── KPI stat card (color-coded) ─────────────────────────────────────────────── + +function HttpStatCard({ + label, value, sub, thresholds, +}: { + label: string; + value: string | number; + sub: string; + thresholds?: { good: number; ok: number; inverse?: boolean }; +}) { + let color = 'var(--text)'; + if (thresholds) { + const n = typeof value === 'number' ? value : parseFloat(String(value)); + if (!isFinite(n)) { + color = 'var(--text)'; + } else if (thresholds.inverse) { + color = n <= thresholds.good ? '#10b981' : n <= thresholds.ok ? '#f59e0b' : '#f43f5e'; + } else { + color = n >= thresholds.good ? '#10b981' : n >= thresholds.ok ? '#f59e0b' : '#f43f5e'; + } + } + return ( +
+ + {label} + + + {value} + + {sub} +
+ ); +} + +// ── Sentry configure tip ────────────────────────────────────────────────────── + +function SentryConfigTip() { + return ( +
+ + + + +
+ + Sentry not configured + + + Add + SENTRY_AUTH_TOKEN,{' '} + + SENTRY_ORG_SLUG, and{' '} + + SENTRY_PROJECT_SLUG{' '} + to + qa-chatbot/.env to pull live error data into this view. + +
+
+ ); +} + +// ── Endpoint latency table ──────────────────────────────────────────────────── + +function EndpointLatencyTable({ rows }: { rows: EndpointLatency[] }) { + if (rows.length === 0) return null; + + const latencyColor = (ms: number) => + ms <= 200 ? '#10b981' : ms <= 500 ? '#f59e0b' : '#f43f5e'; + + return ( +
+
+ + Endpoint Latency + + top 10 by volume · last 30 days +
+ + + + {(['Endpoint', 'Requests', 'P50', 'P95'] as const).map(h => ( + + ))} + + + + {rows.map((row, i) => ( + + + + + + + ))} + +
{h}
+ {row.path} + + {row.count.toLocaleString()} + + {row.p50_ms}ms + + {row.p95_ms}ms +
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function DeveloperMetrics() { + const [selectedIssueId, setSelectedIssueId] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['admin', 'analytics', 'developer_metrics'], + queryFn: async () => { + const res = await api.get<{ data: AnalyticsResponse }>( + '/api/v1/admin/analytics?view=developer_metrics&days=30' + ); + return res.data.data; + }, + staleTime: 5 * 60 * 1000, + }); + + if (isLoading) { + return
Loading…
; + } + if (isError || !data) { + return
Failed to load.
; + } + + const st = data.statics; + const s = (key: string) => getSeries(data, key); + + // HTTP metrics + const httpTotal = Number(st.http_total_requests_30d ?? 0); + const httpErrorRate = Number(st.http_error_rate_pct ?? 0); + const httpP95 = Number(st.http_p95_latency_ms ?? 0); + const http5xxCount = Number(st.http_5xx_count_30d ?? 0); + + // Sentry metrics (-1 = not configured) + const sentryErrors = Number(st.sentry_total_errors ?? -1); + const sentryIssues = Number(st.sentry_unresolved_issues ?? -1); + const sentryCrashFree = Number(st.sentry_crash_free_rate ?? -1); + const sentryTotalSess = Number(st.sentry_total_sessions ?? -1); + const sentryHealthy = Number(st.sentry_healthy_sessions ?? -1); + const sentryCrashed = Number(st.sentry_crashed_sessions ?? -1); + const sentryAccepted = Number(st.sentry_accepted_total ?? -1); + const sentryDiscarded = Number(st.sentry_discarded_total ?? -1); + const sentryFiltered = Number(st.sentry_filtered_total ?? -1); + const sentryConfigured = sentryErrors >= 0; + + // Bridge metrics + const bridgeSuccessRate = Number(st.bridge_success_rate_pct ?? 0); + const bridgeFailureRate = Number(st.bridge_failure_rate_pct ?? 0); + const bridgeReuseRate = Number(st.bridge_reuse_rate_pct ?? 0); + const queueDepth = Number(st.bridge_queue_depth ?? 0); + const totalBridgeJobs = Number(st.total_bridge_jobs ?? 0); + const bridgeFailedTotal = Number(st.bridge_failed_all_time ?? 0); + const totalOptSessions = Number(st.total_optimizer_sessions ?? 0); + const incompleteSessions = Number(st.optimizer_incomplete_sessions ?? 0); + const optCompletionRate = Number(st.optimizer_completion_rate_pct ?? 0); + + const bridgeStatusItems = buildStatusItems(s('dev_bridge_status_dist')?.data ?? []); + const bridgeReuseItems: DistItem[] = (s('dev_bridge_reuse_dist')?.data ?? []).map(p => ({ + label: p.date, + value: p.value, + color: p.date === 'Reused' ? '#06b6d4' : '#8b5cf6', + })); + + const topFailItems: DistItem[] = (s('dev_http_top_failing_paths')?.data ?? []).map((p, i) => ({ + label: p.date, + value: p.value, + color: ['#f43f5e', '#f97316', '#f59e0b', '#eab308', '#84cc16'][i] ?? '#6b7280', + })); + + const endpointLatency: EndpointLatency[] = (data.raw?.endpoint_latency ?? []) as EndpointLatency[]; + const richIssues: SentryIssue[] = (data.raw?.sentry_issues ?? []) as SentryIssue[]; + const releases: SentryRelease[] = (data.raw?.sentry_releases ?? []) as SentryRelease[]; + + // Fallback list from series data when rich_issues not yet loaded (before server restart) + const fallbackIssueItems: DistItem[] = richIssues.length === 0 + ? (s('dev_sentry_top_issues')?.data ?? []).map((p, i) => ({ + label: p.date, + value: p.value, + color: ['#f43f5e', '#f97316', '#f59e0b', '#eab308', '#84cc16', + '#22c55e', '#06b6d4', '#8b5cf6', '#ec4899', '#6b7280'][i] ?? '#6b7280', + })) + : []; + + const sessionHealthItems: DistItem[] = (s('dev_sentry_session_health')?.data ?? []).map(p => ({ + label: p.date.charAt(0).toUpperCase() + p.date.slice(1), + value: p.value, + color: SESSION_COLORS[p.date] ?? '#6b7280', + })); + + const issueLevelItems: DistItem[] = (s('dev_sentry_issue_levels')?.data ?? []).map(p => ({ + label: p.date.charAt(0).toUpperCase() + p.date.slice(1), + value: p.value, + color: LEVEL_COLORS[p.date] ?? '#6b7280', + })); + + const httpHasData = httpTotal > 0; + + return ( +
+ + {/* ── HTTP Health statics ──────────────────────────────────────────── */} +
+ + + + +
+ + {/* ── HTTP Health section ──────────────────────────────────────────── */} + + +
+ {s('dev_http_requests_daily') && } + {s('dev_http_errors_daily') && } + {s('dev_http_5xx_daily') && } +
+ + {topFailItems.length > 0 && ( + + )} + + + + {/* ── Sentry ──────────────────────────────────────────────────────── */} + + + {!sentryConfigured ? ( + + ) : ( + <> + {/* Row 1: Error volume KPIs */} +
+ + + = 0 ? sentryAccepted.toLocaleString() : '—'} + sub="processed by Sentry (30d)" + thresholds={{ good: 0, ok: 50, inverse: true }} + /> + = 0 ? sentryDiscarded.toLocaleString() : '—'} + sub="rate-limited or sampled out" + thresholds={{ good: 0, ok: 20, inverse: true }} + /> +
+ + {/* Row 2: Session health KPIs */} +
+ = 0 ? `${sentryCrashFree}%` : '—'} + sub="session-level stability" + thresholds={{ good: 99.5, ok: 95, inverse: false }} + /> + = 0 ? sentryTotalSess.toLocaleString() : '—'} + sub="last 30 days" + /> + = 0 ? sentryHealthy.toLocaleString() : '—'} + sub="completed without crash" + thresholds={{ good: 90, ok: 50, inverse: false }} + /> + = 0 ? sentryCrashed.toLocaleString() : '—'} + sub="sessions with unhandled error" + thresholds={{ good: 0, ok: 10, inverse: true }} + /> +
+ + {/* Error volume + crash-free trend */} +
+ {s('dev_sentry_errors_daily') && ( + + )} + {s('dev_sentry_crash_free_daily') && ( + + )} +
+ + {/* Accepted / discarded / filtered trends */} +
+ {s('dev_sentry_accepted_daily') && ( + + )} + {s('dev_sentry_discarded_daily') && ( + + )} + {sentryFiltered > 0 && s('dev_sentry_filtered_daily') && ( + + )} +
+ + {/* Session health + issue levels side by side */} +
+ {sessionHealthItems.length > 0 && ( + + )} + {issueLevelItems.length > 0 && ( + + )} +
+ + {/* Full issues table — click opens inline detail panel */} + {richIssues.length > 0 + ? + : fallbackIssueItems.length > 0 && ( + + ) + } + + {/* Release stability */} + {releases.length > 0 && } + + )} + + {/* ── Bridge pipeline statics ──────────────────────────────────────── */} +
+ +
+ + Bridge Success Rate + + = 95 ? '#10b981' : bridgeSuccessRate >= 80 ? '#f59e0b' : '#f43f5e', + lineHeight: 1 }}> + {bridgeSuccessRate}% + + + {(totalBridgeJobs - bridgeFailedTotal).toLocaleString()} / {totalBridgeJobs.toLocaleString()} jobs + +
+ +
+ + Bridge Failure Rate + + + {bridgeFailureRate}% + + + {bridgeFailedTotal.toLocaleString()} failed all time + +
+ +
+ + Bridge Queue Depth + + 10 ? '#f43f5e' : '#f59e0b', + lineHeight: 1 }}> + {queueDepth} + + + non-terminal jobs right now + +
+ +
+ + Optimizer Completion + + = 95 ? '#10b981' : optCompletionRate >= 80 ? '#f59e0b' : '#f43f5e', + lineHeight: 1 }}> + {optCompletionRate}% + + + {incompleteSessions.toLocaleString()} incomplete of {totalOptSessions.toLocaleString()} + +
+ +
+ + {/* Secondary statics: reuse rate + totals */} +
+ + + +
+ + {/* ── Bridge Pipeline ──────────────────────────────────────────────── */} + + +
+ {s('dev_bridge_jobs_daily') && } + {s('dev_bridge_completed_daily') && } + {s('dev_bridge_failed_daily') && } +
+ +
+ + +
+ + {/* ── Optimizer Pipeline ───────────────────────────────────────────── */} + + +
+ {s('dev_optimizer_sessions_daily') && } + {s('dev_incomplete_sessions_daily') && } +
+ + {/* ── API Call Volume ──────────────────────────────────────────────── */} + + +
+ {s('dev_optimize_events_daily') && } + {s('dev_health_score_daily') && } + {s('dev_advisory_daily') && } +
+ + {/* Inline issue detail panel — position:fixed, renders over everything */} + {selectedIssueId && ( + setSelectedIssueId(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/admin/analytics/issue-detail-panel.tsx b/frontend/src/components/admin/analytics/issue-detail-panel.tsx new file mode 100644 index 0000000..648f928 --- /dev/null +++ b/frontend/src/components/admin/analytics/issue-detail-panel.tsx @@ -0,0 +1,875 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/lib/api'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface StackFrame { + filename: string; + lineno: number | null; + function: string; + context: [number, string][]; + in_app: boolean; + vars: Record; +} + +interface ExceptionInfo { + exc_type: string; + exc_value: string; + mechanism: string; + frames: StackFrame[]; +} + +interface RequestInfo { + method: string; + url: string; + query_string: string; + headers: [string, string][]; +} + +interface IssueDetail { + issue: { + id: string; + short_id: string; + title: string; + level: string; + count: number; + user_count: number; + first_seen: string; + last_seen: string; + permalink: string; + culprit: string; + status: string; + }; + latest_event: { + event_id: string; + timestamp: string; + user: { + id: string | null; + email: string | null; + ip: string | null; + geo_city: string | null; + geo_country: string | null; + geo_region: string | null; + }; + tags: { key: string; value: string }[]; + exception: ExceptionInfo | null; + request: RequestInfo | null; + breadcrumbs: { + type: string; category: string; message: string; + level: string; timestamp: string; + }[]; + release: string | null; + }; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function relativeTime(iso: string) { + if (!iso) return '—'; + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.floor(diff / 60_000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return d < 30 ? `${d}d ago` : new Date(iso).toLocaleDateString(); +} + +const LEVEL_COLOR: Record = { + error: '#f43f5e', warning: '#f59e0b', info: '#06b6d4', debug: '#6b7280', +}; + +// ── AI fix payload builder ──────────────────────────────────────────────────── + +function buildAiFixPayload(d: IssueDetail) { + const ev = d.latest_event; + const exc = ev.exception; + + const compressedFrames = exc + ? (exc.frames.filter(f => f.in_app).slice(-8)).map(f => ({ + filename: f.filename, + lineno: f.lineno, + function: f.function, + // max 7 context lines around the error line + context: f.context.slice(-7), + in_app: f.in_app, + // max 4 vars + vars: Object.fromEntries(Object.entries(f.vars).slice(0, 4)), + })) + : []; + + return { + title: d.issue.title, + level: d.issue.level, + culprit: d.issue.culprit, + exception: exc ? { + exc_type: exc.exc_type, + exc_value: exc.exc_value.slice(0, 400), + mechanism: exc.mechanism, + frames: compressedFrames, + } : null, + request_method: ev.request?.method ?? '', + request_url: ev.request?.url ?? '', + // last 3 breadcrumbs only + breadcrumbs: ev.breadcrumbs.slice(-3).map(c => ({ + category: c.category, + message: c.message.slice(0, 100), + timestamp: c.timestamp, + })), + }; +} + +// ── Simple markdown renderer (## headings + ```code``` blocks) ─────────────── + +function AiFixResult({ text }: { text: string }) { + // Split into segments: heading | code | plain text + const segments: { type: 'h2' | 'code' | 'text'; content: string }[] = []; + const codeRe = /```[\w]*\n?([\s\S]*?)```/g; + let remaining = text; + + // Process section by section (split on ## headings) + const sections = remaining.split(/(?=^## )/m); + for (const section of sections) { + const headingMatch = section.match(/^## (.+)\n?/); + if (headingMatch) { + segments.push({ type: 'h2', content: headingMatch[1].trim() }); + remaining = section.slice(headingMatch[0].length); + } else { + remaining = section; + } + + // Within the section body, extract code blocks + let lastIndex = 0; + codeRe.lastIndex = 0; + let match; + while ((match = codeRe.exec(remaining)) !== null) { + if (match.index > lastIndex) { + const plain = remaining.slice(lastIndex, match.index).trim(); + if (plain) segments.push({ type: 'text', content: plain }); + } + segments.push({ type: 'code', content: match[1] }); + lastIndex = match.index + match[0].length; + } + const tail = remaining.slice(lastIndex).trim(); + if (tail) segments.push({ type: 'text', content: tail }); + } + + return ( +
+ {segments.map((seg, i) => { + if (seg.type === 'h2') return ( +
+ {seg.content} +
+ ); + if (seg.type === 'code') return ( +
+
+              {seg.content}
+            
+
+ ); + // Inline code within text: wrap `backtick` spans + const parts = seg.content.split(/`([^`]+)`/); + return ( +

+ {parts.map((p, j) => + j % 2 === 1 + ? {p} + : p + )} +

+ ); + })} +
+ ); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function StackTrace({ exc }: { exc: ExceptionInfo }) { + const [showAll, setShowAll] = useState(false); + const frames = [...exc.frames].reverse(); // newest first + const inAppFrames = frames.filter(f => f.in_app); + const displayed = showAll ? frames : (inAppFrames.length > 0 ? inAppFrames : frames.slice(0, 8)); + + return ( +
+ {/* Exception header */} +
+
+ {exc.exc_type} +
+
+ {exc.exc_value} +
+ {exc.mechanism && ( +
+ mechanism: {exc.mechanism} +
+ )} +
+ + {/* Frames */} + {displayed.map((frame, i) => ( + + ))} + + {/* Toggle */} + {!showAll && frames.length !== displayed.length && ( + + )} +
+ ); +} + +function FrameRow({ frame }: { frame: StackFrame }) { + const [expanded, setExpanded] = useState(frame.in_app); + const hasContext = frame.context && frame.context.length > 0; + + return ( +
+ {/* Frame header */} +
hasContext && setExpanded(e => !e)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '8px 12px', + cursor: hasContext ? 'pointer' : 'default', + }} + > + {frame.in_app && ( + APP + )} + + {frame.filename} + {frame.lineno != null && ( + :{frame.lineno} + )} + + + in {frame.function} + + {hasContext && ( + + {expanded ? '▲' : '▼'} + + )} +
+ + {/* Context lines */} + {expanded && hasContext && ( +
+ {frame.context.map(([lineNo, lineText]) => { + const isErr = lineNo === frame.lineno; + return ( +
+ + {lineNo} + + + {lineText} + +
+ ); + })} +
+ )} + + {/* Local vars */} + {expanded && Object.keys(frame.vars).length > 0 && ( +
+ {Object.entries(frame.vars).map(([k, v]) => ( + + {k} + {' = '} + {v} + + ))} +
+ )} +
+ ); +} + +// ── Coding-agent prompt builder ─────────────────────────────────────────────── + +function buildCodingAgentPrompt(d: IssueDetail, aiAnalysis?: string): string { + const ev = d.latest_event; + const exc = ev.exception; + const inApp = exc ? exc.frames.filter(f => f.in_app) : []; + const topFrame = inApp[inApp.length - 1]; + + const lines: string[] = []; + lines.push('Fix this production error in our FastAPI / Python codebase:\n'); + + // Error identity + if (exc) { + lines.push(`**Error:** \`${exc.exc_type}: ${exc.exc_value.slice(0, 300)}\``); + } else { + lines.push(`**Error:** ${d.issue.title}`); + } + if (d.issue.culprit) lines.push(`**Culprit:** ${d.issue.culprit}`); + lines.push(`**Occurrences:** ${d.issue.count.toLocaleString()} events · ${d.issue.user_count} users affected\n`); + + // Stack trace — in-app frames only + if (inApp.length > 0) { + lines.push('## Stack Trace (in-app frames)\n\n```'); + for (const f of inApp.slice(-6)) { + lines.push(`File "${f.filename}", line ${f.lineno ?? '?'}, in ${f.function}()`); + const errLine = f.context.find(([n]) => n === f.lineno); + if (errLine) lines.push(` ${String(errLine[1]).trim()}`); + } + lines.push('```\n'); + } + + // Primary file with annotated context + if (topFrame) { + lines.push(`## File to Fix\n\nOpen \`${topFrame.filename}\` at line \`${topFrame.lineno ?? '?'}\`.\n`); + if (topFrame.context.length > 0) { + lines.push('```python'); + for (const [lineNo, lineText] of topFrame.context) { + const marker = lineNo === topFrame.lineno ? '>>>' : ' '; + lines.push(`${marker} ${String(lineNo).padStart(4)} | ${lineText}`); + } + lines.push('```\n'); + } + } + + // Request context + if (ev.request?.url) { + lines.push(`## Request\n\n\`${ev.request.method} ${ev.request.url}\`\n`); + } + + // Paste the AI analysis when available — gives the agent a head-start + if (aiAnalysis) { + lines.push('## AI Root-Cause Analysis\n'); + lines.push(aiAnalysis); + lines.push(''); + } + + // Instructions + lines.push('## What to Do\n'); + lines.push(`1. Identify the root cause of the \`${exc?.exc_type ?? 'error'}\` at \`${topFrame?.filename ?? '?'}:${topFrame?.lineno ?? '?'}\``); + lines.push('2. Apply the minimal fix needed — avoid unrelated refactors'); + lines.push('3. Check for the same pattern elsewhere in the codebase'); + lines.push('4. Confirm existing tests still pass after your change'); + + return lines.join('\n'); +} + +// ── Main panel ──────────────────────────────────────────────────────────────── + +export function IssueDetailPanel({ + issueId, + onClose, +}: { + issueId: string; + onClose: () => void; +}) { + // AI fix — enabled on demand; TanStack caches by issueId so the same issue + // never triggers a second API call within the session. + const queryClient = useQueryClient(); + const aiCacheKey = ['admin', 'sentry-ai-fix', issueId]; + const hasCached = !!queryClient.getQueryData(aiCacheKey); + const [aiEnabled, setAiEnabled] = useState(hasCached); + const [aiShown, setAiShown] = useState(hasCached); + const [copied, setCopied] = useState(false); + + const aiQuery = useQuery({ + queryKey: aiCacheKey, + queryFn: async () => { + // data is guaranteed to exist before this fires (aiEnabled only set after data loads) + const payload = buildAiFixPayload(queryClient.getQueryData( + ['admin', 'sentry-issue', issueId] + )!); + const res = await api.post<{ data: { analysis: string } }>( + '/api/v1/admin/sentry/issues/ai-fix', + payload, + ); + return res.data.data.analysis; + }, + enabled: aiEnabled, + staleTime: Infinity, // never re-fetch the same issue + gcTime: 30 * 60 * 1000, + }); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['admin', 'sentry-issue', issueId], + queryFn: async () => { + const res = await api.get<{ data: IssueDetail }>( + `/api/v1/admin/sentry/issues/${issueId}` + ); + return res.data.data; + }, + staleTime: 2 * 60 * 1000, + }); + + const levelColor = data ? (LEVEL_COLOR[data.issue.level] ?? '#6b7280') : '#6b7280'; + + return ( + <> + {/* Backdrop */} +
+ + {/* Drawer */} +
+ {/* Toolbar */} +
+ + {data && ( + <> + + {data.issue.short_id} + + + {data.issue.level} + + + {data.issue.title} + + + + Open in Sentry ↗ + + + )} +
+ + {/* Body */} +
+ {isLoading && ( +
+ Loading issue details… +
+ )} + {isError && ( +
+ Failed to load issue details. +
+ )} + + {data && (() => { + const ev = data.latest_event; + const user = ev.user; + const hasGeo = user.geo_city || user.geo_country; + + return ( + <> + {/* Quick stats */} +
+ {[ + { label: 'Events', val: data.issue.count.toLocaleString() }, + { label: 'Users', val: data.issue.user_count > 0 ? data.issue.user_count.toLocaleString() : '—' }, + { label: 'First seen', val: relativeTime(data.issue.first_seen) }, + { label: 'Last seen', val: relativeTime(data.issue.last_seen) }, + ].map(({ label, val }) => ( +
+
+ {label} +
+
+ {val} +
+
+ ))} +
+ + {/* AI Fix result */} + {aiQuery.isError && ( +
+ AI analysis failed — check that the backend LLM is configured. +
+ )} + {aiShown && aiQuery.data && ( +
+ {/* Header row */} +
+ ✦ AI ANALYSIS + + gpt-4.1-mini · in-app frames only + + {/* Copy prompt button */} + + +
+ +
+ )} + {/* Copy prompt without AI analysis (available immediately) */} + {!aiShown && ( +
+ +
+ )} + + {/* Stack trace */} + {ev.exception && ( + <> + Stack Trace + + + )} + + {/* User & Location */} + {(user.id || user.email || user.ip || hasGeo) && ( + <> + User +
+ {[ + ['ID', user.id], + ['Email', user.email], + ['IP', user.ip], + ['Location', hasGeo ? [user.geo_city, user.geo_region, user.geo_country].filter(Boolean).join(', ') : null], + ].map(([label, val]) => val ? ( +
+ {String(label)}: + {String(val)} +
+ ) : null)} +
+ + )} + + {/* Request */} + {ev.request && ( + <> + Request +
+
0 ? '1px solid var(--border)' : 'none', + }}> + + {ev.request.method} + + + {ev.request.url} + {ev.request.query_string && ( + ?{ev.request.query_string} + )} + +
+ {ev.request.headers.length > 0 && ( +
+ {ev.request.headers + .filter(([k]) => !['cookie', 'Cookie', 'authorization', 'Authorization'].includes(k)) + .slice(0, 8) + .map(([k, v], i) => ( +
+ {k} + : + {String(v)} +
+ )) + } +
+ )} +
+ + )} + + {/* Tags */} + {ev.tags.length > 0 && ( + <> + Tags +
+ {ev.tags.map(t => ( + + {t.key}:{' '} + {t.value} + + ))} +
+ + )} + + {/* Breadcrumbs */} + {ev.breadcrumbs.length > 0 && ( + <> + Breadcrumbs +
+ {ev.breadcrumbs.map((c, i) => ( +
+ + {new Date(c.timestamp).toLocaleTimeString()} + + + {c.category} + + + {c.message} + +
+ ))} +
+ + )} + + {/* Release */} + {ev.release && ( + <> + Release + + {ev.release} + + + )} + + ); + })()} +
+
+ + ); +} diff --git a/frontend/src/components/admin/analytics/issues-table.tsx b/frontend/src/components/admin/analytics/issues-table.tsx new file mode 100644 index 0000000..d73cf45 --- /dev/null +++ b/frontend/src/components/admin/analytics/issues-table.tsx @@ -0,0 +1,191 @@ +'use client'; + +import type { SentryIssue } from '@/types/analytics'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function relativeTime(iso: string): string { + if (!iso) return '—'; + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.floor(diff / 60_000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); +} + +const LEVEL_STYLE: Record = { + error: { bg: 'color-mix(in oklab, #f43f5e 12%, transparent)', text: '#f43f5e', label: 'ERROR' }, + warning: { bg: 'color-mix(in oklab, #f59e0b 12%, transparent)', text: '#f59e0b', label: 'WARN' }, + info: { bg: 'color-mix(in oklab, #06b6d4 12%, transparent)', text: '#06b6d4', label: 'INFO' }, + debug: { bg: 'color-mix(in oklab, #6b7280 12%, transparent)', text: '#6b7280', label: 'DEBUG' }, +}; + +function LevelBadge({ level }: { level: string }) { + const s = LEVEL_STYLE[level] ?? LEVEL_STYLE.error; + return ( + + {s.label} + + ); +} + +// ── Main component ───────────────────────────────────────────────────────────── + +export function IssuesTable({ + issues, + onSelectIssue, +}: { + issues: SentryIssue[]; + onSelectIssue: (id: string) => void; +}) { + if (!issues || issues.length === 0) { + return ( +
+ No unresolved issues +
+ ); + } + + return ( +
+ {/* Header */} +
+ + Unresolved Issues + + + {issues.length} issues · last 14 days · click for details + +
+ + {/* Column headers */} +
+ {['Issue', 'Events', 'Users', 'Level', 'First Seen', 'Last Seen'].map(h => ( + + {h} + + ))} +
+ + {/* Rows */} +
+ {issues.map((issue, idx) => ( +
onSelectIssue(issue.id)} + role="button" + tabIndex={0} + onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onSelectIssue(issue.id)} + style={{ + display: 'grid', + gridTemplateColumns: '1fr 70px 70px 80px 90px 90px', + padding: '11px 18px', + borderBottom: idx < issues.length - 1 ? '1px solid var(--border)' : 'none', + background: 'transparent', + transition: 'background 0.12s', + cursor: 'pointer', + outline: 'none', + }} + onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + onFocus={e => (e.currentTarget.style.boxShadow = 'inset 0 0 0 2px #6366f1')} + onBlur={e => (e.currentTarget.style.boxShadow = 'none')} + > + {/* Title + culprit */} +
+
+ {issue.is_unhandled && ( + + UNHANDLED + + )} + + {issue.title} + +
+
+ + {issue.short_id} + + {issue.culprit && ( + + {issue.culprit} + + )} +
+
+ + {/* Event count */} + + {issue.count.toLocaleString()} + + + {/* User count */} + 0 ? '#f43f5e' : 'var(--text-muted)', + fontWeight: issue.user_count > 0 ? 700 : 400, + alignSelf: 'center' }}> + {issue.user_count > 0 ? issue.user_count.toLocaleString() : '—'} + + + {/* Level badge */} +
+ +
+ + {/* First seen */} + + {relativeTime(issue.first_seen)} + + + {/* Last seen */} + + {relativeTime(issue.last_seen)} + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/admin/analytics/releases-card.tsx b/frontend/src/components/admin/analytics/releases-card.tsx new file mode 100644 index 0000000..26d267a --- /dev/null +++ b/frontend/src/components/admin/analytics/releases-card.tsx @@ -0,0 +1,108 @@ +'use client'; + +import type { SentryRelease } from '@/types/analytics'; + +function shortHash(version: string): string { + return version.length > 12 ? version.slice(0, 8) : version; +} + +function formatDate(iso: string): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +export function ReleasesCard({ releases }: { releases: SentryRelease[] }) { + if (!releases || releases.length === 0) { + return null; + } + + const withIssues = releases.filter(r => r.new_groups > 0); + const totalNewIssues = releases.reduce((s, r) => s + r.new_groups, 0); + + return ( +
+
+ + Recent Releases + +
+ + {releases.length} releases + + 0 ? '#f59e0b' : '#10b981', + background: totalNewIssues > 0 + ? 'color-mix(in oklab, #f59e0b 10%, transparent)' + : 'color-mix(in oklab, #10b981 10%, transparent)', + padding: '2px 8px', borderRadius: 10, + }}> + {totalNewIssues} new issues introduced + +
+
+ +
+ {releases.map((r, idx) => ( +
0 + ? 'color-mix(in oklab, #f59e0b 4%, transparent)' : 'transparent', + }}> + {/* Version hash */} + + {shortHash(r.version)} + + + {/* Date */} + + {formatDate(r.date_created)} + + + {/* New issues badge */} +
+ {r.new_groups > 0 ? ( + + +{r.new_groups} new issue{r.new_groups !== 1 ? 's' : ''} + + ) : ( + clean + )} +
+ + {/* Commit count */} + + {r.commit_count > 0 ? `${r.commit_count} commits` : ''} + +
+ ))} +
+ + {withIssues.length > 0 && ( +
+ {withIssues.length} of {releases.length} releases introduced new issues +
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/view-tab.tsx b/frontend/src/components/admin/view-tab.tsx index 48f7f5a..f92ff40 100644 --- a/frontend/src/components/admin/view-tab.tsx +++ b/frontend/src/components/admin/view-tab.tsx @@ -8,16 +8,18 @@ import { AgentOptimizer } from './analytics/agent-optimizer'; import { AgentSkillOpt } from './analytics/agent-skillopt'; import { AgentDomain } from './analytics/agent-domain'; import { AgentBridge } from './analytics/agent-bridge'; +import { DeveloperMetrics } from './analytics/developer-metrics'; type TopToggle = 'platform' | 'agents'; -type PlatformView = 'feature_engagement' | 'login_activity' | 'user_metrics'; +type PlatformView = 'feature_engagement' | 'login_activity' | 'user_metrics' | 'developer_metrics'; type AgentView = 'prompt_optimizer' | 'skill_builder' | 'domain_pdogepa' | 'bridge'; const PLATFORM_ITEMS: { id: PlatformView; label: string }[] = [ - { id: 'feature_engagement', label: 'Feature Engagement' }, - { id: 'login_activity', label: 'Login Activity' }, - { id: 'user_metrics', label: 'User Metrics' }, + { id: 'feature_engagement', label: 'Feature Engagement' }, + { id: 'login_activity', label: 'Login Activity' }, + { id: 'user_metrics', label: 'User Metrics' }, + { id: 'developer_metrics', label: 'Developer Metrics' }, ]; const AGENT_ITEMS: { id: AgentView; label: string }[] = [ @@ -65,6 +67,8 @@ export function ViewTab() { desc: 'Track login activity and daily, weekly, monthly active user trends' }, user_metrics: { title: 'User Metrics', desc: 'User growth, new signups, and daily/weekly active user trends' }, + developer_metrics: { title: 'Developer Metrics', + desc: 'HTTP request health, Sentry error tracking, bridge pipeline health, and optimizer session outcomes' }, prompt_optimizer: { title: 'Prompt Optimizer', desc: 'Council optimizer runs, token consumption, and model distribution' }, skill_builder: { title: 'Skill Builder', @@ -134,6 +138,7 @@ export function ViewTab() { {toggle === 'platform' && platformView === 'feature_engagement' && } {toggle === 'platform' && platformView === 'login_activity' && } {toggle === 'platform' && platformView === 'user_metrics' && } + {toggle === 'platform' && platformView === 'developer_metrics' && } {toggle === 'agents' && agentView === 'prompt_optimizer' && } {toggle === 'agents' && agentView === 'skill_builder' && } {toggle === 'agents' && agentView === 'domain_pdogepa' && } diff --git a/frontend/src/components/bridge/transfer-detail.tsx b/frontend/src/components/bridge/transfer-detail.tsx index c6ae000..d16b49b 100644 --- a/frontend/src/components/bridge/transfer-detail.tsx +++ b/frontend/src/components/bridge/transfer-detail.tsx @@ -494,7 +494,7 @@ export function TransferDetail({ }}>
- +
{mapping?.avg_target_score != null && ( diff --git a/frontend/src/components/domain-prompts/domain-card.tsx b/frontend/src/components/domain-prompts/domain-card.tsx index 12c1829..25c343e 100644 --- a/frontend/src/components/domain-prompts/domain-card.tsx +++ b/frontend/src/components/domain-prompts/domain-card.tsx @@ -114,7 +114,7 @@ export function DomainCard({ fontSize: 11, color: '#5a5a60', fontFamily: 'var(--font-geist-mono, monospace)', }}> - {domain.dataset.row_count} data sources + {domain.dataset.row_count} Q&A pairs )} {domain.optimized_prompt && ( diff --git a/frontend/src/components/domain-prompts/domain-workspace.tsx b/frontend/src/components/domain-prompts/domain-workspace.tsx index 0122bd3..a8fb639 100644 --- a/frontend/src/components/domain-prompts/domain-workspace.tsx +++ b/frontend/src/components/domain-prompts/domain-workspace.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { toast } from 'sonner'; import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; import { api } from '@/lib/api'; import type { DomainPrompt, DomainListResponse, DatasetRowsResponse, QAPair, TournamentState, OptimizationRun, RunListResponse } from '@/types/domain-prompts'; @@ -1545,17 +1546,25 @@ export function DomainWorkspace() { ); setPollingDomainId(capturedDomainId); setPollingJobId(res.data.data.job_id); - } catch { setReoptimizing(false); } + } catch (err: unknown) { + setReoptimizing(false); + const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + toast.error(typeof detail === 'string' ? detail : 'Failed to start optimization — please try again.'); + } }, [selected]); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); const handleDelete = useCallback(async () => { if (!selected) return; - if (!window.confirm('Delete this domain and all its data? This cannot be undone.')) return; try { await api.delete(`/api/v1/domain-prompts/${selected.id}`); setSelectedId(null); + setConfirmDeleteId(null); void qc.invalidateQueries({ queryKey: ['domain-prompts'] }); - } catch { /* ignore */ } + } catch { + toast.error('Failed to delete domain — please try again.'); + setConfirmDeleteId(null); + } }, [selected, qc]); const [cancelling, setCancelling] = useState(false); @@ -1658,9 +1667,17 @@ export function DomainWorkspace() { {recovering ? 'Restoring…' : 'Restore to Ready'} )} - + {confirmDeleteId === selected?.id ? ( + <> + Delete? + + + + ) : ( + + )}
); diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index 630d734..0f2c4ae 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -94,7 +94,23 @@ function formatTokens(n: number): string { return String(n); } -function TokenCard({ tokenBalance }: { tokenBalance: number }) { +function TokenCard({ tokenBalance }: { tokenBalance: number | undefined | null }) { + if (tokenBalance === null) return null; + if (tokenBalance === undefined) { + return ( +
+
+ Tokens +
+
+
+ +
+
+
+ ); + } + // Clamp display at 0 — never reveal the internal overdraft buffer to users. const displayed = Math.max(0, tokenBalance); const isDepleted = displayed === 0; @@ -123,7 +139,7 @@ function TokenCard({ tokenBalance }: { tokenBalance: number }) { } function RecentSessions() { - const { data } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ['sessions'], queryFn: async () => { const res = await api.get<{ data: SessionsGrouped }>('/api/v1/chat/sessions'); @@ -132,6 +148,18 @@ function RecentSessions() { staleTime: 60_000, }); + if (isLoading) { + return ( +
+ {[80, 65, 90].map((w, i) => ( +
+ ))} +
+ ); + } + + if (isError) return null; + const sessions: SessionSummary[] = data ? [...data.today, ...data.last_7_days, ...data.last_30_days, ...data.older].slice(0, 5) : []; @@ -176,7 +204,7 @@ function RecentSessions() { export function Sidebar() { const pathname = usePathname(); - const { data: fetchedUser } = useQuery({ + const { data: fetchedUser, isError: userFetchError } = useQuery({ queryKey: ['user', 'me'], queryFn: async () => { const res = await api.get<{ data: User }>('/api/v1/users/me'); @@ -185,7 +213,7 @@ export function Sidebar() { staleTime: 1000 * 60 * 5, }); - const tokenBalance = fetchedUser?.token_balance ?? TOKEN_START; + const tokenBalance = userFetchError ? null : fetchedUser?.token_balance; return (