Files
stonks-oracle/frontend/src/pages/SqlExplorer.tsx
T
Celes Renata 1107d34027 fix: SQL Explorer handles comments and shows descriptive errors
- Strip SQL comments (-- and /* */) before checking for SELECT,
  so queries with leading comments don't get rejected
- Show the actual error detail from the API response instead of
  generic 'API error 400' in the SQL Explorer UI
2026-04-16 05:25:45 +00:00

304 lines
12 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';
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 SchemaInfo {
catalog: string;
schema: string;
tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable?: boolean }> }>;
}
type ChartType = 'none' | '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 { 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);
}, []);
// Build chart data from result
const chartData = result && result.columns.length >= 2
? result.rows.map((row) => ({
x: row[xCol],
y: Number(row[yCol]) || 0,
label: String(row[xCol]),
}))
: [];
// 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>
{schema?.tables.map((t) => (
<div key={t.name} className="mb-2">
<button
className="text-xs font-medium text-brand-300 hover:underline"
onClick={() => handleQuickInsert(t.name)}
title={`SELECT * FROM ${t.name} LIMIT 100`}
>
{t.name}
</button>
<div className="ml-2 space-y-0.5">
{t.columns.map((c) => (
<div key={c.name} className="flex justify-between text-[10px]">
<span className="text-gray-400">{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', '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}
</button>
))}
{chartType !== 'none' && (
<>
<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>
</>
)}
</div>
{chartType !== 'none' && chartData.length > 0 && (
<ResponsiveContainer width="100%" height={250}>
{chartType === '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 contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Bar dataKey="y" fill="#3b82f6" />
</BarChart>
) : chartType === '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 contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<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[xCol]?.name} />
<YAxis dataKey="y" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[yCol]?.name} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Scatter data={chartData} fill="#3b82f6" />
</ScatterChart>
)}
</ResponsiveContainer>
)}
</Card>
)}
</div>
</div>
);
}