phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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 }> }>;
|
||||
}
|
||||
|
||||
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: ['trino-schema'],
|
||||
queryFn: () => apiGet<SchemaInfo>('query', '/api/analytics/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/query', { sql: sqlText, limit: 1000 }),
|
||||
onSuccess: (data) => { setResult(data); setError(null); },
|
||||
onError: (err: Error) => { setError(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]);
|
||||
|
||||
// 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]),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3rem)] gap-4">
|
||||
{/* Schema browser sidebar */}
|
||||
<div className="w-56 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={() => setSql((prev) => prev + ` ${t.name}`)}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Saved queries */}
|
||||
<h2 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Saved Queries</h2>
|
||||
{(savedQueries ?? []).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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user