mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
517f5faed3
commit
d95ad80029
5 changed files with 786 additions and 658 deletions
|
|
@ -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 (
|
||||
<div className="min-h-full">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
654
apps/arc-web/app/routes/insights-editor.tsx
Normal file
654
apps/arc-web/app/routes/insights-editor.tsx
Normal file
|
|
@ -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<Record<string, string | number>>;
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-navy-600">
|
||||
No numeric columns to chart
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div ref={containerRef}>
|
||||
{containerWidth > 0 && (
|
||||
<svg
|
||||
width={containerWidth}
|
||||
height={chartHeight}
|
||||
className="select-none"
|
||||
>
|
||||
{/* Y-axis gridlines + labels */}
|
||||
{yTicks.map((tick, i) => {
|
||||
const y = padding.top + (plotHeight * i) / (tickCount - 1);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={yAxisWidth}
|
||||
y1={y}
|
||||
x2={containerWidth - padding.right}
|
||||
y2={y}
|
||||
stroke="rgba(255,255,255,0.04)"
|
||||
strokeDasharray="4,4"
|
||||
/>
|
||||
<text
|
||||
x={yAxisWidth - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fill="rgba(75, 87, 104, 1)"
|
||||
fontSize="11"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{tick >= 1000 ? `${(tick / 1000).toFixed(tick >= 10000 ? 0 : 1)}k` : tick}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<g key={label + i}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
rx={3}
|
||||
fill={BAR_COLORS[colorIndex]}
|
||||
className="transition-opacity hover:opacity-100"
|
||||
opacity={0.85}
|
||||
/>
|
||||
<title>
|
||||
{label}: {formatNumber(numVal)}
|
||||
</title>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={chartHeight - padding.bottom + 16}
|
||||
textAnchor="middle"
|
||||
fill="rgba(75, 87, 104, 1)"
|
||||
fontSize="10"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{label.length > maxLabelLen
|
||||
? label.slice(0, Math.max(3, maxLabelLen - 1)) + "\u2026"
|
||||
: label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-4 px-2">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-navy-600">
|
||||
{valueCol.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table component ──
|
||||
|
||||
function ResultTable({ result }: { result: QueryResult }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
{result.columns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="whitespace-nowrap px-3 py-2.5 font-mono text-[11px] font-semibold uppercase tracking-wider text-navy-600"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-white/[0.03] transition-colors hover:bg-white/[0.02]"
|
||||
>
|
||||
{result.columns.map((col) => {
|
||||
const val = row[col];
|
||||
const isNum = typeof val === "number";
|
||||
return (
|
||||
<td
|
||||
key={col}
|
||||
className={`whitespace-nowrap px-3 py-2 font-mono text-xs ${
|
||||
isNum ? "tabular-nums text-ice-100" : "text-ice-300"
|
||||
}`}
|
||||
>
|
||||
{isNum ? formatNumber(val) : String(val)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SQL Editor with line numbers ──
|
||||
|
||||
function SqlEditor({
|
||||
value,
|
||||
onChange,
|
||||
onRun,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
// 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 (
|
||||
<div className="relative flex overflow-hidden rounded-md border border-white/[0.06] bg-navy-950/80 font-mono text-sm">
|
||||
{/* Line numbers */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
className="pointer-events-none flex shrink-0 flex-col overflow-hidden border-r border-white/[0.04] bg-navy-950/60 px-3 py-3 text-right leading-[1.625rem] text-navy-600 select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<span key={i} className="text-[11px]">
|
||||
{i + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="min-h-[7lh] w-full resize-y bg-transparent px-4 py-3 leading-[1.625rem] text-ice-100 placeholder-navy-600 outline-none"
|
||||
placeholder="SELECT * FROM runs LIMIT 10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ──
|
||||
|
||||
const DEFAULT_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";
|
||||
|
||||
export default function InsightsEditor() {
|
||||
const location = useLocation();
|
||||
const navState = location.state as { sql?: string; name?: string } | null;
|
||||
|
||||
const [sql, setSql] = useState(navState?.sql ?? DEFAULT_SQL);
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [resultView, setResultView] = useState<ResultView>("chart");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [queryName, setQueryName] = useState(navState?.name ?? "Run duration by workflow");
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showAiDialog, setShowAiDialog] = useState(false);
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
|
||||
const runQuery = useCallback(() => {
|
||||
setIsRunning(true);
|
||||
const delay = 200 + Math.random() * 400;
|
||||
setTimeout(() => {
|
||||
setResult(generateMockResult(sql));
|
||||
setIsRunning(false);
|
||||
}, delay);
|
||||
}, [sql]);
|
||||
|
||||
// Load query from navigation state
|
||||
useEffect(() => {
|
||||
if (navState?.sql) {
|
||||
setSql(navState.sql);
|
||||
if (navState.name) {
|
||||
setQueryName(navState.name);
|
||||
}
|
||||
}
|
||||
}, [navState]);
|
||||
|
||||
// Run default query on mount
|
||||
const hasRun = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasRun.current) {
|
||||
hasRun.current = true;
|
||||
runQuery();
|
||||
}
|
||||
}, [runQuery]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Toolbar + Editor ── */}
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
{/* Query name */}
|
||||
{isEditingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={queryName}
|
||||
onChange={(e) => setQueryName(e.target.value)}
|
||||
onBlur={() => setIsEditingName(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "Escape") {
|
||||
setIsEditingName(false);
|
||||
}
|
||||
}}
|
||||
placeholder="Untitled query"
|
||||
className="min-w-0 max-w-xs rounded border border-teal-500/40 bg-navy-950/60 px-2 py-0.5 text-sm font-medium text-ice-100 placeholder-navy-600 outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-ice-100">
|
||||
{queryName || "Untitled query"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingName(true);
|
||||
requestAnimationFrame(() => nameInputRef.current?.select());
|
||||
}}
|
||||
className="rounded p-0.5 text-navy-600 transition-colors hover:bg-white/[0.05] hover:text-ice-300"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push buttons to the right */}
|
||||
<div className="ml-auto" />
|
||||
|
||||
{/* SQL AI */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAiDialog(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/[0.06] px-3 py-1.5 text-sm font-medium text-ice-300 transition-colors hover:border-teal-500/30 hover:bg-white/[0.03] hover:text-white"
|
||||
>
|
||||
<SparklesIcon className="size-3.5 text-teal-500" />
|
||||
SQL AI
|
||||
</button>
|
||||
|
||||
{/* Save */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/[0.06] px-3 py-1.5 text-sm font-medium text-ice-300 transition-colors hover:border-white/[0.12] hover:bg-white/[0.03] hover:text-white"
|
||||
>
|
||||
<BookmarkIcon className="size-3.5" />
|
||||
Save
|
||||
</button>
|
||||
|
||||
{/* Run */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runQuery}
|
||||
disabled={isRunning || sql.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-mint/20 bg-mint/5 px-3.5 py-1.5 text-sm font-medium text-mint transition-all hover:border-mint/50 hover:bg-mint/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-mint/20 disabled:hover:bg-mint/5 disabled:hover:text-mint"
|
||||
>
|
||||
{isRunning ? (
|
||||
<ArrowPathIcon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="size-3.5" />
|
||||
)}
|
||||
{isRunning ? "Running\u2026" : "Run"}
|
||||
<kbd className="ml-1 hidden rounded border border-white/[0.08] bg-white/[0.04] px-1 py-0.5 font-sans text-[10px] leading-none text-navy-600 sm:inline">
|
||||
{"\u2318\u21B5"}
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SqlEditor value={sql} onChange={setSql} onRun={runQuery} />
|
||||
</div>
|
||||
|
||||
{/* ── Results bar + content ── */}
|
||||
{result && (
|
||||
<>
|
||||
{/* Results bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Query stats */}
|
||||
<div className="flex items-center gap-5 font-mono text-[11px] tabular-nums text-navy-600">
|
||||
<span>
|
||||
Elapsed:{" "}
|
||||
<span className="text-ice-300">
|
||||
{result.elapsed.toFixed(3)}s
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Read:{" "}
|
||||
<span className="text-ice-300">
|
||||
{formatNumber(result.rowsRead)} rows
|
||||
</span>{" "}
|
||||
({formatBytes(result.bytesRead)})
|
||||
</span>
|
||||
<span>
|
||||
Returned:{" "}
|
||||
<span className="text-ice-300">
|
||||
{formatNumber(result.rowsReturned)} rows
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/[0.06] bg-navy-800/80 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResultView("chart")}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
resultView === "chart"
|
||||
? "bg-white/[0.06] text-teal-500"
|
||||
: "text-navy-600 hover:text-ice-300"
|
||||
}`}
|
||||
>
|
||||
<ChartBarIcon className="size-3.5" />
|
||||
Chart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResultView("table")}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
resultView === "table"
|
||||
? "bg-white/[0.06] text-teal-500"
|
||||
: "text-navy-600 hover:text-ice-300"
|
||||
}`}
|
||||
>
|
||||
<TableCellsIcon className="size-3.5" />
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results content */}
|
||||
<div className="rounded-md border border-white/[0.06] bg-navy-800/60 p-4">
|
||||
{resultView === "chart" ? (
|
||||
<BarChart result={result} />
|
||||
) : (
|
||||
<ResultTable result={result} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Running overlay ── */}
|
||||
{isRunning && !result && (
|
||||
<div className="flex h-48 items-center justify-center rounded-md border border-white/[0.06] bg-navy-800/60">
|
||||
<div className="flex items-center gap-3 text-sm text-navy-600">
|
||||
<ArrowPathIcon className="size-5 animate-spin text-teal-500" />
|
||||
Executing query…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── AI Dialog ── */}
|
||||
<Dialog
|
||||
open={showAiDialog}
|
||||
onClose={() => setShowAiDialog(false)}
|
||||
className="relative z-50"
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-start justify-center pt-[15vh]">
|
||||
<DialogPanel className="w-full max-w-lg rounded-lg border border-white/[0.08] bg-navy-800 shadow-2xl shadow-black/40">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-3.5">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<SparklesIcon className="size-4 text-teal-500" />
|
||||
SQL AI
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAiDialog(false)}
|
||||
className="text-navy-600 transition-colors hover:text-ice-300"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<label className="mb-2 block text-xs font-medium text-ice-300">
|
||||
Describe what you want to query
|
||||
</label>
|
||||
<textarea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder="e.g. Show me the average build time per workflow over the last 30 days"
|
||||
className="w-full rounded-md border border-white/[0.06] bg-navy-950/60 px-3 py-2.5 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAiDialog(false)}
|
||||
className="rounded-md border border-white/[0.06] px-3 py-1.5 text-sm text-ice-300 transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSql(
|
||||
"-- AI-generated query based on: " +
|
||||
aiPrompt +
|
||||
"\nSELECT workflow_name,\n AVG(duration_seconds) as avg_duration,\n COUNT(*) as run_count\nFROM runs\nWHERE created_at >= CURRENT_DATE - INTERVAL '30 days'\nGROUP BY workflow_name\nORDER BY avg_duration DESC",
|
||||
);
|
||||
setAiPrompt("");
|
||||
setShowAiDialog(false);
|
||||
}}
|
||||
disabled={aiPrompt.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-teal-500/30 bg-teal-500/10 px-3 py-1.5 text-sm font-medium text-teal-300 transition-all hover:border-teal-500/50 hover:bg-teal-500/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
Generate SQL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
apps/arc-web/app/routes/insights-new.tsx
Normal file
99
apps/arc-web/app/routes/insights-new.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Link, useNavigate } from "react-router";
|
||||
import {
|
||||
SparklesIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ServerStackIcon,
|
||||
CommandLineIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
const templateQueries = [
|
||||
{
|
||||
title: "Run duration by workflow",
|
||||
description: "Average execution time and run count per workflow",
|
||||
icon: ClockIcon,
|
||||
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",
|
||||
},
|
||||
{
|
||||
title: "Daily failure rate",
|
||||
description: "Failures, totals, and failure percentage by day",
|
||||
icon: ExclamationTriangleIcon,
|
||||
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",
|
||||
},
|
||||
{
|
||||
title: "Top repos by activity",
|
||||
description: "Run count and code churn per repository",
|
||||
icon: ServerStackIcon,
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
export default function InsightsNew() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl py-12">
|
||||
{/* LLM input */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-teal-500" />
|
||||
<h2 className="text-lg font-semibold text-white">New Query</h2>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Ask assistant to generate a report"
|
||||
className="w-full rounded-lg border border-white/[0.06] bg-navy-950/80 px-4 py-3.5 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-teal-500/30 bg-teal-500/10 px-4 py-2 text-sm font-medium text-teal-300 transition-all hover:border-teal-500/50 hover:bg-teal-500/20 hover:text-white"
|
||||
>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template cards */}
|
||||
<div className="mt-10 space-y-3">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-navy-600">
|
||||
Start from a template
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{templateQueries.map((tpl) => (
|
||||
<button
|
||||
key={tpl.title}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate("/insights", { state: { sql: tpl.sql, name: tpl.title } });
|
||||
}}
|
||||
className="flex items-start gap-3.5 rounded-lg border border-white/[0.06] bg-navy-800/60 px-4 py-3.5 text-left transition-colors hover:border-white/[0.12] hover:bg-navy-800/80"
|
||||
>
|
||||
<tpl.icon className="mt-0.5 size-5 shrink-0 text-navy-600" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-ice-100">
|
||||
{tpl.title}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-navy-600">
|
||||
{tpl.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQL link */}
|
||||
<div className="mt-10 text-center">
|
||||
<Link
|
||||
to="/insights"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-navy-600 transition-colors hover:text-ice-300"
|
||||
>
|
||||
<CommandLineIcon className="size-4" />
|
||||
<span className="font-semibold">Write my own report with SQL</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,5 @@
|
|||
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 { Link, Outlet, useNavigate } from "react-router";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import type { Route } from "./+types/insights";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
|
|
@ -27,22 +12,13 @@ export const handle = {
|
|||
|
||||
// ── Types ──
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string | number>>;
|
||||
elapsed: number;
|
||||
rowsRead: number;
|
||||
bytesRead: number;
|
||||
rowsReturned: number;
|
||||
}
|
||||
|
||||
interface SavedQuery {
|
||||
export interface SavedQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
sql: string;
|
||||
timestamp: string;
|
||||
|
|
@ -50,11 +26,9 @@ interface HistoryEntry {
|
|||
rowsReturned: number;
|
||||
}
|
||||
|
||||
type ResultView = "chart" | "table";
|
||||
|
||||
// ── Mock data ──
|
||||
|
||||
const savedQueries: SavedQuery[] = [
|
||||
export const savedQueries: SavedQuery[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Run duration by workflow",
|
||||
|
|
@ -72,7 +46,7 @@ const savedQueries: SavedQuery[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const historyEntries: HistoryEntry[] = [
|
||||
export 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 },
|
||||
|
|
@ -80,404 +54,21 @@ const historyEntries: HistoryEntry[] = [
|
|||
{ 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<HTMLDivElement>(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 (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-navy-600">
|
||||
No numeric columns to chart
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div ref={containerRef}>
|
||||
{containerWidth > 0 && (
|
||||
<svg
|
||||
width={containerWidth}
|
||||
height={chartHeight}
|
||||
className="select-none"
|
||||
>
|
||||
{/* Y-axis gridlines + labels */}
|
||||
{yTicks.map((tick, i) => {
|
||||
const y = padding.top + (plotHeight * i) / (tickCount - 1);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={yAxisWidth}
|
||||
y1={y}
|
||||
x2={containerWidth - padding.right}
|
||||
y2={y}
|
||||
stroke="rgba(255,255,255,0.04)"
|
||||
strokeDasharray="4,4"
|
||||
/>
|
||||
<text
|
||||
x={yAxisWidth - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fill="rgba(75, 87, 104, 1)"
|
||||
fontSize="11"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{tick >= 1000 ? `${(tick / 1000).toFixed(tick >= 10000 ? 0 : 1)}k` : tick}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<g key={label + i}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
rx={3}
|
||||
fill={BAR_COLORS[colorIndex]}
|
||||
className="transition-opacity hover:opacity-100"
|
||||
opacity={0.85}
|
||||
/>
|
||||
<title>
|
||||
{label}: {formatNumber(numVal)}
|
||||
</title>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={chartHeight - padding.bottom + 16}
|
||||
textAnchor="middle"
|
||||
fill="rgba(75, 87, 104, 1)"
|
||||
fontSize="10"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{label.length > maxLabelLen
|
||||
? label.slice(0, Math.max(3, maxLabelLen - 1)) + "\u2026"
|
||||
: label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-4 px-2">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-navy-600">
|
||||
{valueCol.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table component ──
|
||||
|
||||
function ResultTable({ result }: { result: QueryResult }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
{result.columns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="whitespace-nowrap px-3 py-2.5 font-mono text-[11px] font-semibold uppercase tracking-wider text-navy-600"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-white/[0.03] transition-colors hover:bg-white/[0.02]"
|
||||
>
|
||||
{result.columns.map((col) => {
|
||||
const val = row[col];
|
||||
const isNum = typeof val === "number";
|
||||
return (
|
||||
<td
|
||||
key={col}
|
||||
className={`whitespace-nowrap px-3 py-2 font-mono text-xs ${
|
||||
isNum ? "tabular-nums text-ice-100" : "text-ice-300"
|
||||
}`}
|
||||
>
|
||||
{isNum ? formatNumber(val) : String(val)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SQL Editor with line numbers ──
|
||||
|
||||
function SqlEditor({
|
||||
value,
|
||||
onChange,
|
||||
onRun,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
// 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 (
|
||||
<div className="relative flex overflow-hidden rounded-md border border-white/[0.06] bg-navy-950/80 font-mono text-sm">
|
||||
{/* Line numbers */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
className="pointer-events-none flex shrink-0 flex-col overflow-hidden border-r border-white/[0.04] bg-navy-950/60 px-3 py-3 text-right leading-[1.625rem] text-navy-600 select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<span key={i} className="text-[11px]">
|
||||
{i + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="min-h-[7lh] w-full resize-y bg-transparent px-4 py-3 leading-[1.625rem] text-ice-100 placeholder-navy-600 outline-none"
|
||||
placeholder="SELECT * FROM runs LIMIT 10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ──
|
||||
|
||||
export default function Insights() {
|
||||
const defaultSql =
|
||||
"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";
|
||||
|
||||
const [sql, setSql] = useState(defaultSql);
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [resultView, setResultView] = useState<ResultView>("chart");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [queryName, setQueryName] = useState("Run duration by workflow");
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showAiDialog, setShowAiDialog] = useState(false);
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
|
||||
const runQuery = useCallback(() => {
|
||||
setIsRunning(true);
|
||||
// Simulate query execution
|
||||
const delay = 200 + Math.random() * 400;
|
||||
setTimeout(() => {
|
||||
setResult(generateMockResult(sql));
|
||||
setIsRunning(false);
|
||||
}, delay);
|
||||
}, [sql]);
|
||||
|
||||
// Run default query on mount
|
||||
const hasRun = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasRun.current) {
|
||||
hasRun.current = true;
|
||||
runQuery();
|
||||
}
|
||||
}, [runQuery]);
|
||||
|
||||
const loadSavedQuery = (query: SavedQuery) => {
|
||||
setSql(query.sql);
|
||||
};
|
||||
|
||||
const loadHistoryEntry = (entry: HistoryEntry) => {
|
||||
setSql(entry.sql);
|
||||
};
|
||||
export default function InsightsLayout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
{/* ── Sidebar ── */}
|
||||
<div className="w-56 shrink-0">
|
||||
<div className="sticky top-6 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
to="/insights/new"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-white/[0.06] bg-navy-800/80 px-3 py-2 text-sm font-medium text-ice-300 transition-colors hover:border-white/[0.12] hover:bg-navy-800 hover:text-white"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
New Query
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-navy-600">
|
||||
|
|
@ -489,14 +80,9 @@ export default function Insights() {
|
|||
key={q.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
loadSavedQuery(q);
|
||||
setQueryName(q.name);
|
||||
navigate("/insights", { state: { sql: q.sql, name: q.name } });
|
||||
}}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-md px-2.5 py-2 text-left transition-colors hover:bg-white/[0.05] ${
|
||||
queryName === q.name
|
||||
? "bg-white/[0.05] text-white"
|
||||
: ""
|
||||
}`}
|
||||
className="flex w-full flex-col gap-0.5 rounded-md px-2.5 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
>
|
||||
<span className="text-sm font-medium text-ice-100">
|
||||
{q.name}
|
||||
|
|
@ -518,7 +104,9 @@ export default function Insights() {
|
|||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => loadHistoryEntry(entry)}
|
||||
onClick={() => {
|
||||
navigate("/insights", { state: { sql: entry.sql } });
|
||||
}}
|
||||
className="flex w-full flex-col gap-0.5 rounded-md px-2.5 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
>
|
||||
<span className="truncate font-mono text-[10px] text-ice-300">
|
||||
|
|
@ -535,234 +123,9 @@ export default function Insights() {
|
|||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
{/* ── Toolbar + Editor ── */}
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
{/* Query name */}
|
||||
{isEditingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={queryName}
|
||||
onChange={(e) => setQueryName(e.target.value)}
|
||||
onBlur={() => setIsEditingName(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "Escape") {
|
||||
setIsEditingName(false);
|
||||
}
|
||||
}}
|
||||
placeholder="Untitled query"
|
||||
className="min-w-0 max-w-xs rounded border border-teal-500/40 bg-navy-950/60 px-2 py-0.5 text-sm font-medium text-ice-100 placeholder-navy-600 outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-ice-100">
|
||||
{queryName || "Untitled query"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingName(true);
|
||||
requestAnimationFrame(() => nameInputRef.current?.select());
|
||||
}}
|
||||
className="rounded p-0.5 text-navy-600 transition-colors hover:bg-white/[0.05] hover:text-ice-300"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push buttons to the right */}
|
||||
<div className="ml-auto" />
|
||||
|
||||
{/* SQL AI */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAiDialog(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/[0.06] px-3 py-1.5 text-sm font-medium text-ice-300 transition-colors hover:border-teal-500/30 hover:bg-white/[0.03] hover:text-white"
|
||||
>
|
||||
<SparklesIcon className="size-3.5 text-teal-500" />
|
||||
SQL AI
|
||||
</button>
|
||||
|
||||
{/* Save */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/[0.06] px-3 py-1.5 text-sm font-medium text-ice-300 transition-colors hover:border-white/[0.12] hover:bg-white/[0.03] hover:text-white"
|
||||
>
|
||||
<BookmarkIcon className="size-3.5" />
|
||||
Save
|
||||
</button>
|
||||
|
||||
{/* Run */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runQuery}
|
||||
disabled={isRunning || sql.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-mint/20 bg-mint/5 px-3.5 py-1.5 text-sm font-medium text-mint transition-all hover:border-mint/50 hover:bg-mint/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-mint/20 disabled:hover:bg-mint/5 disabled:hover:text-mint"
|
||||
>
|
||||
{isRunning ? (
|
||||
<ArrowPathIcon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="size-3.5" />
|
||||
)}
|
||||
{isRunning ? "Running\u2026" : "Run"}
|
||||
<kbd className="ml-1 hidden rounded border border-white/[0.08] bg-white/[0.04] px-1 py-0.5 font-sans text-[10px] leading-none text-navy-600 sm:inline">
|
||||
{"⌘↵"}
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SqlEditor value={sql} onChange={setSql} onRun={runQuery} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* ── Results bar + content ── */}
|
||||
{result && (
|
||||
<>
|
||||
{/* Results bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Query stats */}
|
||||
<div className="flex items-center gap-5 font-mono text-[11px] tabular-nums text-navy-600">
|
||||
<span>
|
||||
Elapsed:{" "}
|
||||
<span className="text-ice-300">
|
||||
{result.elapsed.toFixed(3)}s
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Read:{" "}
|
||||
<span className="text-ice-300">
|
||||
{formatNumber(result.rowsRead)} rows
|
||||
</span>{" "}
|
||||
({formatBytes(result.bytesRead)})
|
||||
</span>
|
||||
<span>
|
||||
Returned:{" "}
|
||||
<span className="text-ice-300">
|
||||
{formatNumber(result.rowsReturned)} rows
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/[0.06] bg-navy-800/80 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResultView("chart")}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
resultView === "chart"
|
||||
? "bg-white/[0.06] text-teal-500"
|
||||
: "text-navy-600 hover:text-ice-300"
|
||||
}`}
|
||||
>
|
||||
<ChartBarIcon className="size-3.5" />
|
||||
Chart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResultView("table")}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
resultView === "table"
|
||||
? "bg-white/[0.06] text-teal-500"
|
||||
: "text-navy-600 hover:text-ice-300"
|
||||
}`}
|
||||
>
|
||||
<TableCellsIcon className="size-3.5" />
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results content */}
|
||||
<div className="rounded-md border border-white/[0.06] bg-navy-800/60 p-4">
|
||||
{resultView === "chart" ? (
|
||||
<BarChart result={result} />
|
||||
) : (
|
||||
<ResultTable result={result} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Running overlay ── */}
|
||||
{isRunning && !result && (
|
||||
<div className="flex h-48 items-center justify-center rounded-md border border-white/[0.06] bg-navy-800/60">
|
||||
<div className="flex items-center gap-3 text-sm text-navy-600">
|
||||
<ArrowPathIcon className="size-5 animate-spin text-teal-500" />
|
||||
Executing query\u2026
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── AI Dialog ── */}
|
||||
<Dialog
|
||||
open={showAiDialog}
|
||||
onClose={() => setShowAiDialog(false)}
|
||||
className="relative z-50"
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-start justify-center pt-[15vh]">
|
||||
<DialogPanel className="w-full max-w-lg rounded-lg border border-white/[0.08] bg-navy-800 shadow-2xl shadow-black/40">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-3.5">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<SparklesIcon className="size-4 text-teal-500" />
|
||||
SQL AI
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAiDialog(false)}
|
||||
className="text-navy-600 transition-colors hover:text-ice-300"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<label className="mb-2 block text-xs font-medium text-ice-300">
|
||||
Describe what you want to query
|
||||
</label>
|
||||
<textarea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder="e.g. Show me the average build time per workflow over the last 30 days"
|
||||
className="w-full rounded-md border border-white/[0.06] bg-navy-950/60 px-3 py-2.5 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAiDialog(false)}
|
||||
className="rounded-md border border-white/[0.06] px-3 py-1.5 text-sm text-ice-300 transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSql(
|
||||
"-- AI-generated query based on: " +
|
||||
aiPrompt +
|
||||
"\nSELECT workflow_name,\n AVG(duration_seconds) as avg_duration,\n COUNT(*) as run_count\nFROM runs\nWHERE created_at >= CURRENT_DATE - INTERVAL '30 days'\nGROUP BY workflow_name\nORDER BY avg_duration DESC",
|
||||
);
|
||||
setAiPrompt("");
|
||||
setShowAiDialog(false);
|
||||
}}
|
||||
disabled={aiPrompt.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-teal-500/30 bg-teal-500/10 px-3 py-1.5 text-sm font-medium text-teal-300 transition-all hover:border-teal-500/50 hover:bg-teal-500/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
Generate SQL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>{/* end main content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue