Files
stonks-oracle/frontend/src/pages/SqlExplorer.tsx
T
Celes Renata 419cf7558a fix: evidence articles missing on recommendations + Lucide title prop CI failure
- recommendation worker: filter out non-UUID document IDs (synthetic
  pattern:* IDs from competitive signals) before inserting into
  recommendation_evidence table — the uuid cast was failing and
  silently dropping all evidence rows
- wrap executemany in try/except so partial failures don't lose all evidence
- SqlExplorer: wrap Lucide icons in <span title=...> instead of passing
  title prop directly (not supported by lucide-react, broke CI build)
2026-04-17 07:10:21 +00:00

476 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Editor from '@monaco-editor/react';
import { apiGet, apiPost, apiDelete } from '../api/client';
import { LoadingSpinner, Card } from '../components/ui';
import {
BarChart, Bar, LineChart, Line, ScatterChart, Scatter,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import { ChevronDown, ChevronRight, Key, Link, Search } from 'lucide-react';
interface QueryResult {
columns: Array<{ name: string; type: string }>;
rows: unknown[][];
row_count: number;
elapsed_ms: number;
}
interface SavedQuery {
id: string;
name: string;
description: string;
sql_text: string;
created_at: string;
}
interface SchemaColumn {
name: string;
type: string;
nullable?: boolean;
primary_key?: boolean;
references?: string;
has_default?: boolean;
}
interface SchemaTable {
name: string;
row_estimate?: number;
columns: SchemaColumn[];
}
interface SchemaInfo {
catalog: string;
schema: string;
tables: SchemaTable[];
}
type ChartType = 'none' | 'auto' | 'bar' | 'line' | 'scatter';
export function SqlExplorerPage() {
const qc = useQueryClient();
const [sql, setSql] = useState('SELECT 1 AS test');
const [result, setResult] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [chartType, setChartType] = useState<ChartType>('none');
const [xCol, setXCol] = useState(0);
const [yCol, setYCol] = useState(1);
const [schemaFilter, setSchemaFilter] = useState('');
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
const { data: schema } = useQuery<SchemaInfo>({
queryKey: ['pg-schema'],
queryFn: () => apiGet<SchemaInfo>('query', '/api/analytics/pg-schema'),
});
const { data: savedQueries } = useQuery<SavedQuery[]>({
queryKey: ['saved-queries'],
queryFn: () => apiGet<SavedQuery[]>('query', '/api/analytics/saved-queries'),
});
const executeMutation = useMutation({
mutationFn: (sqlText: string) => apiPost<QueryResult>('query', '/api/analytics/pg-query', { sql: sqlText, limit: 1000 }),
onSuccess: (data) => { setResult(data); setError(null); },
onError: (err: Error) => {
const detail = (err as { body?: { detail?: string } }).body?.detail;
setError(detail || err.message);
setResult(null);
},
});
const saveMutation = useMutation({
mutationFn: (body: { name: string; sql_text: string }) => apiPost<SavedQuery>('query', '/api/analytics/saved-queries', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete<unknown>('query', `/api/analytics/saved-queries/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
});
const handleExecute = useCallback(() => {
if (sql.trim()) executeMutation.mutate(sql);
}, [sql, executeMutation]);
const handleSave = useCallback(() => {
const name = prompt('Query name:');
if (name) saveMutation.mutate({ name, sql_text: sql });
}, [sql, saveMutation]);
const handleQuickInsert = useCallback((tableName: string) => {
const query = `SELECT * FROM ${tableName} LIMIT 100`;
setSql(query);
}, []);
const toggleTable = useCallback((name: string) => {
setExpandedTables((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
const formatRowCount = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
};
const filteredTables = (schema?.tables ?? []).filter((t) => {
if (!schemaFilter) return true;
const q = schemaFilter.toLowerCase();
return t.name.toLowerCase().includes(q) ||
t.columns.some((c) => c.name.toLowerCase().includes(q));
});
// Build chart data from result
// Auto-detect best chart type and columns when chartType === 'auto'
const autoChart = (() => {
if (!result || result.columns.length < 2 || result.row_count === 0) {
return { type: 'bar' as const, x: 0, y: 1 };
}
const cols = result.columns;
const rows = result.rows;
// Classify each column as numeric or categorical
const isNumeric = cols.map((_, ci) => {
const sample = rows.slice(0, 10);
const numCount = sample.filter((r) => {
const v = r[ci];
return v != null && !isNaN(parseFloat(String(v))) && String(v).trim() !== '';
}).length;
return numCount > sample.length * 0.7;
});
// Check if a column looks like a date/time
const isDateLike = cols.map((c, ci) => {
const name = c.name.toLowerCase();
if (name.includes('date') || name.includes('time') || name.includes('_at') || name.includes('created') || name.includes('generated')) return true;
const sample = String(rows[0]?.[ci] ?? '');
return /^\d{4}-\d{2}/.test(sample);
});
const numericCols = cols.map((_, i) => i).filter((i) => isNumeric[i]);
const categoricalCols = cols.map((_, i) => i).filter((i) => !isNumeric[i]);
const dateCols = cols.map((_, i) => i).filter((i) => isDateLike[i]);
// Heuristics:
// 1. Date column + numeric → line chart (time series)
// 2. Categorical + numeric → bar chart (categories)
// 3. Two numeric columns → scatter plot
// 4. Fallback → bar chart with first categorical as X, first numeric as Y
if (dateCols.length > 0 && numericCols.length > 0) {
const xIdx = dateCols[0];
const yIdx = numericCols.find((i) => i !== xIdx) ?? numericCols[0];
return { type: 'line' as const, x: xIdx, y: yIdx };
}
if (categoricalCols.length > 0 && numericCols.length > 0) {
return { type: 'bar' as const, x: categoricalCols[0], y: numericCols[0] };
}
if (numericCols.length >= 2) {
return { type: 'scatter' as const, x: numericCols[0], y: numericCols[1] };
}
// Fallback: first col as X, second as Y, bar chart
return { type: 'bar' as const, x: 0, y: Math.min(1, cols.length - 1) };
})();
// Resolve effective chart settings (auto overrides manual selections)
const effectiveChartType = chartType === 'auto' ? autoChart.type : chartType;
const effectiveXCol = chartType === 'auto' ? autoChart.x : xCol;
const effectiveYCol = chartType === 'auto' ? autoChart.y : yCol;
const chartData = result && result.columns.length >= 2
? result.rows.map((row) => {
const yRaw = row[effectiveYCol];
const yNum = yRaw != null ? parseFloat(String(yRaw)) : NaN;
const entry: Record<string, unknown> = {
x: row[effectiveXCol],
y: isNaN(yNum) ? 0 : yNum,
label: String(row[effectiveXCol] ?? ''),
};
// Attach all column values for rich tooltips
for (let i = 0; i < result.columns.length; i++) {
entry[`_col${i}`] = row[i];
}
return entry;
})
: [];
// Custom tooltip that shows all row values on hover
const renderTooltip = ({ active, payload }: Record<string, unknown>) => {
if (!active || !result) return null;
const items = payload as Array<{ payload: Record<string, unknown> }> | undefined;
if (!items?.length) return null;
const data = items[0].payload;
return (
<div className="rounded-lg border border-surface-700 bg-surface-900 px-3 py-2 text-xs shadow-lg">
{result.columns.map((col, i) => {
const val = data[`_col${i}`];
const isY = i === effectiveYCol;
const isX = i === effectiveXCol;
return (
<div key={i} className={`flex justify-between gap-4 ${isY ? 'font-semibold text-brand-300' : isX ? 'text-gray-200' : 'text-gray-400'}`}>
<span>{col.name}:</span>
<span>{val == null ? 'NULL' : String(val)}</span>
</div>
);
})}
</div>
);
};
// Split saved queries into pre-built (no id-based delete) and user-saved
const preBuiltNames = new Set([
'Company Overview', 'Recent Recommendations', 'High Confidence Buys',
'Trend Windows Summary', 'Market Prices', 'Document Counts by Type',
'Global Events', 'Trading Decisions', 'Ingestion Health',
'Reserve Pool Ledger', 'Sector Exposure', 'Source Coverage Matrix',
]);
const preBuiltQueries = (savedQueries ?? []).filter((sq) => preBuiltNames.has(sq.name));
const userQueries = (savedQueries ?? []).filter((sq) => !preBuiltNames.has(sq.name));
return (
<div className="flex h-[calc(100vh-3rem)] gap-4">
{/* Schema browser sidebar */}
<div className="w-64 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Schema</h2>
<div className="relative mb-2">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={schemaFilter}
onChange={(e) => setSchemaFilter(e.target.value)}
placeholder="Filter tables…"
className="w-full rounded border border-surface-700 bg-surface-800 py-1 pl-6 pr-2 text-xs text-gray-300 placeholder-gray-600 focus:border-brand-500 focus:outline-none"
/>
</div>
<p className="mb-2 text-[10px] text-gray-600">{schema?.tables.length ?? 0} tables</p>
{filteredTables.map((t) => {
const isExpanded = expandedTables.has(t.name);
return (
<div key={t.name} className="mb-1">
<div className="flex items-center gap-1">
<button
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200"
onClick={() => toggleTable(t.name)}
aria-expanded={isExpanded}
aria-label={`Toggle ${t.name} columns`}
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
<button
className="flex-1 truncate text-left text-xs font-medium text-brand-300 hover:underline"
onClick={() => handleQuickInsert(t.name)}
title={`SELECT * FROM ${t.name} LIMIT 100`}
>
{t.name}
</button>
{t.row_estimate != null && (
<span className="text-[10px] text-gray-600" title={`~${t.row_estimate.toLocaleString()} rows`}>
{formatRowCount(t.row_estimate)}
</span>
)}
</div>
{isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5">
{t.columns.map((c) => (
<div key={c.name} className="flex items-center justify-between text-[10px]">
<span className="flex items-center gap-1 text-gray-400">
{c.primary_key && <span title="Primary key"><Key size={9} className="text-amber-500" /></span>}
{c.references && <span title={`FK → ${c.references}`}><Link size={9} className="text-blue-400" /></span>}
{c.name}
</span>
<span className="text-gray-600">{c.type}</span>
</div>
))}
</div>
)}
</div>
);
})}
{/* Pre-built queries */}
{preBuiltQueries.length > 0 && (
<>
<h2 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Pre-built Queries</h2>
{preBuiltQueries.map((sq) => (
<div key={sq.id} className="mb-1.5">
<button
className="block w-full truncate text-left text-xs text-brand-300 hover:text-brand-200"
onClick={() => setSql(sq.sql_text)}
title={sq.sql_text}
>
{sq.name}
</button>
{sq.description && (
<p className="ml-0 truncate text-[10px] text-gray-600" title={sq.description}>
{sq.description}
</p>
)}
</div>
))}
</>
)}
{/* User saved queries */}
<h2 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Saved Queries</h2>
{userQueries.length === 0 && (
<p className="text-[10px] text-gray-600">No saved queries yet</p>
)}
{userQueries.map((sq) => (
<div key={sq.id} className="mb-1 flex items-center justify-between">
<button
className="truncate text-xs text-gray-300 hover:text-brand-300"
onClick={() => setSql(sq.sql_text)}
title={sq.description || sq.name}
>
{sq.name}
</button>
<button
className="text-[10px] text-gray-600 hover:text-red-400"
onClick={() => deleteMutation.mutate(sq.id)}
aria-label={`Delete ${sq.name}`}
>
×
</button>
</div>
))}
</div>
{/* Main area */}
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
{/* Editor */}
<div className="h-48 shrink-0 overflow-hidden rounded-lg border border-surface-700">
<Editor
height="100%"
defaultLanguage="sql"
value={sql}
onChange={(v) => setSql(v ?? '')}
theme="vs-dark"
options={{ minimap: { enabled: false }, fontSize: 13, lineNumbers: 'on', scrollBeyondLastLine: false }}
/>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<button
onClick={handleExecute}
disabled={executeMutation.isPending}
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{executeMutation.isPending ? 'Running…' : 'Execute'}
</button>
<button onClick={handleSave} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
Save
</button>
{result && (
<span className="text-xs text-gray-500">
{result.row_count} rows in {result.elapsed_ms}ms
</span>
)}
{error && <span className="text-xs text-red-400">{error}</span>}
</div>
{/* Results */}
{executeMutation.isPending && <LoadingSpinner />}
{result && (
<div className="flex-1 overflow-auto rounded-lg border border-surface-700">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-surface-900">
<tr>
{result.columns.map((col, i) => (
<th key={i} className="px-2 py-1.5 text-left font-medium text-gray-400">
{col.name} <span className="text-gray-600">({col.type})</span>
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, ri) => (
<tr key={ri} className="border-t border-surface-700/30 hover:bg-surface-800/30">
{row.map((cell, ci) => (
<td key={ci} className="px-2 py-1 text-gray-300">
{cell == null ? <span className="text-gray-600">NULL</span> : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Chart builder */}
{result && result.columns.length >= 2 && (
<Card className="shrink-0">
<div className="mb-2 flex items-center gap-3">
<span className="text-xs text-gray-500">Chart:</span>
{(['none', 'auto', 'bar', 'line', 'scatter'] as ChartType[]).map((ct) => (
<button
key={ct}
onClick={() => setChartType(ct)}
className={`rounded px-2 py-0.5 text-xs ${chartType === ct ? 'bg-brand-600 text-white' : 'text-gray-400 hover:bg-surface-800'}`}
>
{ct === 'none' ? 'Off' : ct === 'auto' ? '✨ Auto' : ct}
</button>
))}
{chartType !== 'none' && chartType !== 'auto' && (
<>
<select value={xCol} onChange={(e) => setXCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="X axis column">
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
</select>
<span className="text-xs text-gray-600"></span>
<select value={yCol} onChange={(e) => setYCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="Y axis column">
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
</select>
</>
)}
{chartType === 'auto' && result && (
<span className="text-[10px] text-gray-500">
{autoChart.type} · X: {result.columns[autoChart.x]?.name} · Y: {result.columns[autoChart.y]?.name}
</span>
)}
</div>
{chartType !== 'none' && chartData.length > 0 && (
<ResponsiveContainer width="100%" height={250}>
{effectiveChartType === 'bar' ? (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip content={renderTooltip} />
<Bar dataKey="y" fill="#3b82f6" />
</BarChart>
) : effectiveChartType === 'line' ? (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip content={renderTooltip} />
<Line type="monotone" dataKey="y" stroke="#3b82f6" dot={false} />
</LineChart>
) : (
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="x" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[effectiveXCol]?.name} />
<YAxis dataKey="y" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[effectiveYCol]?.name} />
<Tooltip content={renderTooltip} />
<Scatter data={chartData} fill="#3b82f6" />
</ScatterChart>
)}
</ResponsiveContainer>
)}
</Card>
)}
</div>
</div>
);
}