From d95ad80029efbc79e3cb03940a59cb712bd8fc10 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 1 Mar 2026 13:49:58 -0500 Subject: [PATCH] Add /insights/new page for creating queries via LLM or templates Convert insights route to a parent layout with sidebar and two children: - index route (insights-editor) with the existing SQL editor - /insights/new with LLM input, template cards, and SQL link Also fix wide layout detection in app-shell to check all matched routes instead of only the last match. Co-Authored-By: Claude Opus 4.6 --- apps/arc-web/app/layouts/app-shell.tsx | 11 +- apps/arc-web/app/routes.ts | 7 +- apps/arc-web/app/routes/insights-editor.tsx | 654 +++++++++++++++++++ apps/arc-web/app/routes/insights-new.tsx | 99 +++ apps/arc-web/app/routes/insights.tsx | 673 +------------------- 5 files changed, 786 insertions(+), 658 deletions(-) create mode 100644 apps/arc-web/app/routes/insights-editor.tsx create mode 100644 apps/arc-web/app/routes/insights-new.tsx diff --git a/apps/arc-web/app/layouts/app-shell.tsx b/apps/arc-web/app/layouts/app-shell.tsx index f191a79b7..b01dab917 100644 --- a/apps/arc-web/app/layouts/app-shell.tsx +++ b/apps/arc-web/app/layouts/app-shell.tsx @@ -9,7 +9,10 @@ import { } from "@headlessui/react"; import { Bars3Icon, + ChartBarIcon, + CheckBadgeIcon, Cog6ToothIcon, + LightBulbIcon, PlayIcon, RectangleStackIcon, SparklesIcon, @@ -28,6 +31,9 @@ const navigation = [ { name: "Start", href: "/start", icon: SparklesIcon }, { name: "Workflows", href: "/workflows", icon: RectangleStackIcon }, { name: "Runs", href: "/runs", icon: PlayIcon }, + { name: "Verifications", href: "/verifications", icon: CheckBadgeIcon }, + { name: "Retros", href: "/retros", icon: LightBulbIcon }, + { name: "Insights", href: "/insights", icon: ChartBarIcon }, { name: "Settings", href: "/settings", icon: Cog6ToothIcon }, ]; @@ -43,10 +49,11 @@ export default function AppShell() { const currentNav = navigation.find((item) => pathname.startsWith(item.href)); const title = currentNav?.name ?? ""; const lastMatch = matches[matches.length - 1]; - const handle = lastMatch?.handle as { headerExtra?: React.ReactNode; wide?: boolean } | undefined; + const handle = lastMatch?.handle as { headerExtra?: React.ReactNode } | undefined; const headerExtra = handle?.headerExtra; const hideHeader = matches.some((m) => (m.handle as { hideHeader?: boolean } | undefined)?.hideHeader); - const maxWidth = handle?.wide ? "" : "max-w-5xl"; + const wide = matches.some((m) => (m.handle as { wide?: boolean } | undefined)?.wide); + const maxWidth = wide ? "" : "max-w-5xl"; return (
diff --git a/apps/arc-web/app/routes.ts b/apps/arc-web/app/routes.ts index 00baa467f..db3d7241e 100644 --- a/apps/arc-web/app/routes.ts +++ b/apps/arc-web/app/routes.ts @@ -25,7 +25,12 @@ export default [ route("files", "routes/run-files-changed.tsx"), route("usage", "routes/run-usage.tsx"), ]), - route("insights", "routes/insights.tsx"), + route("verifications", "routes/verifications.tsx"), + route("retros", "routes/retros.tsx"), + route("insights", "routes/insights.tsx", [ + index("routes/insights-editor.tsx"), + route("new", "routes/insights-new.tsx"), + ]), route("settings", "routes/settings.tsx"), ]), ] satisfies RouteConfig; diff --git a/apps/arc-web/app/routes/insights-editor.tsx b/apps/arc-web/app/routes/insights-editor.tsx new file mode 100644 index 000000000..65d1f7f62 --- /dev/null +++ b/apps/arc-web/app/routes/insights-editor.tsx @@ -0,0 +1,654 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useLocation } from "react-router"; +import { + Dialog, + DialogPanel, + DialogTitle, +} from "@headlessui/react"; +import { + PlayIcon, + BookmarkIcon, + SparklesIcon, + TableCellsIcon, + ChartBarIcon, + XMarkIcon, + ArrowPathIcon, + PencilIcon, +} from "@heroicons/react/24/outline"; + +// ── Types ── + +interface QueryResult { + columns: string[]; + rows: Array>; + elapsed: number; + rowsRead: number; + bytesRead: number; + rowsReturned: number; +} + +type ResultView = "chart" | "table"; + +// ── Mock data ── + +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 */} +