1107d34027
- 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
304 lines
12 KiB
TypeScript
304 lines
12 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';
|
||
|
||
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>
|
||
);
|
||
}
|