419cf7558a
- 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)
476 lines
19 KiB
TypeScript
476 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|