From 810e8b42279aca84cc8972fc962b41e6201aaeff Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 1 Mar 2026 13:38:25 -0500 Subject: [PATCH] Add Insights page with SQL workbench UI SQL query editor with line numbers, saved queries sidebar, history, SQL AI dialog, chart/table results toggle, and query stats bar. Co-Authored-By: Claude Opus 4.6 --- apps/arc-web/app/routes/insights.tsx | 763 ++++++++++++++++++++++++++- 1 file changed, 761 insertions(+), 2 deletions(-) diff --git a/apps/arc-web/app/routes/insights.tsx b/apps/arc-web/app/routes/insights.tsx index 3aff13bf8..a5a386db1 100644 --- a/apps/arc-web/app/routes/insights.tsx +++ b/apps/arc-web/app/routes/insights.tsx @@ -1,9 +1,768 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { + Dialog, + DialogPanel, + DialogTitle, +} from "@headlessui/react"; +import { + PlayIcon, + BookmarkIcon, + SparklesIcon, + TableCellsIcon, + ChartBarIcon, + XMarkIcon, + ArrowPathIcon, + PencilIcon, + PlusIcon, +} from "@heroicons/react/24/outline"; import type { Route } from "./+types/insights"; export function meta({}: Route.MetaArgs) { return [{ title: "Insights — Arc" }]; } -export default function Insights() { - return null; +export const handle = { + wide: true, +}; + +// ── Types ── + +interface QueryResult { + columns: string[]; + rows: Array>; + elapsed: number; + rowsRead: number; + bytesRead: number; + rowsReturned: number; +} + +interface SavedQuery { + id: string; + name: string; + sql: string; +} + +interface HistoryEntry { + id: string; + sql: string; + timestamp: string; + elapsed: number; + rowsReturned: number; +} + +type ResultView = "chart" | "table"; + +// ── Mock data ── + +const savedQueries: SavedQuery[] = [ + { + id: "1", + name: "Run duration by workflow", + sql: "SELECT workflow_name, AVG(duration_seconds) as avg_duration,\n COUNT(*) as run_count\nFROM runs\nGROUP BY workflow_name\nORDER BY avg_duration DESC\nLIMIT 20", + }, + { + id: "2", + name: "Daily failure rate", + sql: "SELECT date_trunc('day', created_at) as day,\n COUNT(*) FILTER (WHERE status = 'failed') as failures,\n COUNT(*) as total,\n ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'failed') / COUNT(*), 1) as failure_rate\nFROM runs\nGROUP BY 1\nORDER BY 1 DESC\nLIMIT 30", + }, + { + id: "3", + name: "Top repos by activity", + sql: "SELECT repo, COUNT(*) as runs, SUM(additions) as total_additions,\n SUM(deletions) as total_deletions\nFROM runs\nGROUP BY repo\nORDER BY runs DESC", + }, +]; + +const historyEntries: HistoryEntry[] = [ + { id: "h1", sql: "SELECT workflow_name, COUNT(*) FROM runs GROUP BY 1", timestamp: "2 min ago", elapsed: 0.342, rowsReturned: 6 }, + { id: "h2", sql: "SELECT * FROM runs WHERE status = 'failed' LIMIT 100", timestamp: "8 min ago", elapsed: 0.127, rowsReturned: 23 }, + { id: "h3", sql: "SELECT date_trunc('day', created_at) as d, COUNT(*) FROM runs GROUP BY 1 ORDER BY 1", timestamp: "15 min ago", elapsed: 0.531, rowsReturned: 30 }, + { id: "h4", sql: "SELECT repo, AVG(duration_seconds) FROM runs GROUP BY repo", timestamp: "1 hr ago", elapsed: 0.089, rowsReturned: 12 }, + { id: "h5", sql: "DESCRIBE runs", timestamp: "1 hr ago", elapsed: 0.003, rowsReturned: 18 }, +]; + +function generateMockResult(sql: string): QueryResult { + const lowerSql = sql.toLowerCase(); + + if (lowerSql.includes("workflow_name") && lowerSql.includes("avg")) { + return { + columns: ["workflow_name", "avg_duration", "run_count"], + rows: [ + { workflow_name: "Expand Product", avg_duration: 342.5, run_count: 48 }, + { workflow_name: "Implement Feature", avg_duration: 287.3, run_count: 156 }, + { workflow_name: "Security Scan", avg_duration: 198.1, run_count: 312 }, + { workflow_name: "Fix Build", avg_duration: 145.7, run_count: 482 }, + { workflow_name: "Sync Drift", avg_duration: 89.2, run_count: 94 }, + { workflow_name: "Dependency Audit", avg_duration: 67.4, run_count: 201 }, + ], + elapsed: 0.531, + rowsRead: 5182366, + bytesRead: 357780000, + rowsReturned: 6, + }; + } + + if (lowerSql.includes("failure_rate") || lowerSql.includes("failed")) { + return { + columns: ["day", "failures", "total", "failure_rate"], + rows: Array.from({ length: 14 }, (_, i) => { + const d = new Date(); + d.setDate(d.getDate() - i); + const total = 80 + Math.floor(Math.random() * 60); + const failures = Math.floor(Math.random() * 15); + return { + day: d.toISOString().slice(0, 10), + failures, + total, + failure_rate: Math.round((1000 * failures) / total) / 10, + }; + }), + elapsed: 0.287, + rowsRead: 2841092, + bytesRead: 198400000, + rowsReturned: 14, + }; + } + + return { + columns: ["repo", "runs", "total_additions", "total_deletions"], + rows: [ + { repo: "arc-engine", runs: 482, total_additions: 28450, total_deletions: 12300 }, + { repo: "arc-web", runs: 356, total_additions: 19200, total_deletions: 8900 }, + { repo: "arc-cli", runs: 198, total_additions: 8700, total_deletions: 4200 }, + { repo: "arc-docs", runs: 145, total_additions: 12100, total_deletions: 3400 }, + { repo: "arc-sdk", runs: 89, total_additions: 5600, total_deletions: 2100 }, + { repo: "arc-infra", runs: 67, total_additions: 3200, total_deletions: 1800 }, + { repo: "arc-actions", runs: 42, total_additions: 2100, total_deletions: 980 }, + { repo: "arc-proto", runs: 28, total_additions: 1400, total_deletions: 650 }, + ], + elapsed: 0.148, + rowsRead: 1204588, + bytesRead: 89200000, + rowsReturned: 8, + }; +} + +// ── Formatting helpers ── + +function formatBytes(bytes: number): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`; + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(2)} MB`; + if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(2)} KB`; + return `${bytes} B`; +} + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +// ── Chart component ── + +const BAR_COLORS = [ + "rgba(90, 200, 168, 0.85)", + "rgba(103, 178, 215, 0.85)", + "rgba(181, 221, 239, 0.65)", + "rgba(240, 164, 91, 0.75)", +]; + +function BarChart({ result }: { result: QueryResult }) { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setContainerWidth(entry.contentRect.width); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const labelCol = result.columns[0]; + const valueCols = result.columns.slice(1).filter((col) => { + const firstVal = result.rows[0]?.[col]; + return typeof firstVal === "number"; + }); + + if (valueCols.length === 0 || result.rows.length === 0) { + return ( +
+ No numeric columns to chart +
+ ); + } + + const valueCol = valueCols[0]; + const maxVal = Math.max(...result.rows.map((r) => { + const v = r[valueCol]; + return typeof v === "number" ? v : 0; + })); + + const chartHeight = 260; + const yAxisWidth = 52; + const padding = { top: 12, bottom: 48, right: 16 }; + const plotHeight = chartHeight - padding.top - padding.bottom; + const plotWidth = containerWidth - yAxisWidth - padding.right; + const barCount = result.rows.length; + const gap = Math.max(8, Math.min(16, plotWidth / barCount * 0.3)); + const barWidth = Math.max(12, (plotWidth - gap * (barCount + 1)) / barCount); + + const tickCount = 5; + const yTicks = Array.from({ length: tickCount }, (_, i) => + Math.round((maxVal * (tickCount - 1 - i)) / (tickCount - 1)), + ); + + return ( +
+ {containerWidth > 0 && ( + + {/* Y-axis gridlines + labels */} + {yTicks.map((tick, i) => { + const y = padding.top + (plotHeight * i) / (tickCount - 1); + return ( + + + + {tick >= 1000 ? `${(tick / 1000).toFixed(tick >= 10000 ? 0 : 1)}k` : tick} + + + ); + })} + + {/* Bars */} + {result.rows.map((row, i) => { + const val = row[valueCol]; + const numVal = typeof val === "number" ? val : 0; + const barHeight = maxVal > 0 ? (numVal / maxVal) * plotHeight : 0; + const x = yAxisWidth + gap + i * (barWidth + gap); + const y = padding.top + plotHeight - barHeight; + const label = String(row[labelCol]); + const colorIndex = i % BAR_COLORS.length; + const maxLabelLen = Math.floor(barWidth / 6); + + return ( + + + + {label}: {formatNumber(numVal)} + + + {label.length > maxLabelLen + ? label.slice(0, Math.max(3, maxLabelLen - 1)) + "\u2026" + : label} + + + ); + })} + + )} +
+ + {valueCol.replace(/_/g, " ")} + +
+
+ ); +} + +// ── Table component ── + +function ResultTable({ result }: { result: QueryResult }) { + return ( +
+ + + + {result.columns.map((col) => ( + + ))} + + + + {result.rows.map((row, i) => ( + + {result.columns.map((col) => { + const val = row[col]; + const isNum = typeof val === "number"; + return ( + + ); + })} + + ))} + +
+ {col} +
+ {isNum ? formatNumber(val) : String(val)} +
+
+ ); +} + +// ── SQL Editor with line numbers ── + +function SqlEditor({ + value, + onChange, + onRun, +}: { + value: string; + onChange: (v: string) => void; + onRun: () => void; +}) { + const textareaRef = useRef(null); + const lineNumbersRef = useRef(null); + const lineCount = value.split("\n").length; + + const syncScroll = useCallback(() => { + if (textareaRef.current && lineNumbersRef.current) { + lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop; + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Ctrl/Cmd + Enter to run + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onRun(); + return; + } + // Tab inserts spaces + if (e.key === "Tab") { + e.preventDefault(); + const textarea = e.currentTarget; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newValue = value.slice(0, start) + " " + value.slice(end); + onChange(newValue); + requestAnimationFrame(() => { + textarea.selectionStart = start + 2; + textarea.selectionEnd = start + 2; + }); + } + }; + + return ( +
+ {/* Line numbers */} + + {/* Textarea */} +