Skip to content

Commit d48d43b

Browse files
Display query results in Raw or Table view
1 parent 2277942 commit d48d43b

File tree

2 files changed

+224
-55
lines changed

2 files changed

+224
-55
lines changed

apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.scss

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,59 @@
2020
resize: none;
2121
font-size: $font-size-base;
2222
white-space: pre-wrap;
23-
padding: 1rem 0 1rem 2rem;
23+
padding: 1rem;
2424
margin-bottom: 1.5rem;
2525
background-color: $body-bg-light;
26-
border-radius: $border-radius;
26+
border-radius: $border-radius-sm;
2727
border: $input-border-width solid $input-border-color;
2828
box-shadow: 0px 1px 2px rgba($dark-blue, 0.05);
29+
30+
&.terminal-table-wrapper {
31+
padding: 0;
32+
display: flex;
33+
flex-direction: column;
34+
35+
.terminal-table-header {
36+
padding: 0.75rem 1rem 0.25rem;
37+
flex-shrink: 0;
38+
}
39+
40+
.ps {
41+
flex: 1;
42+
min-height: 0;
43+
overflow: hidden !important;
44+
&.ps--active-x > .ps__rail-x {
45+
display: block !important;
46+
background-color: transparent;
47+
opacity: 1;
48+
z-index: 99;
49+
height: 0.5rem;
50+
& .ps__thumb-x {
51+
height: 0.25rem;
52+
background-color: #DFB316;
53+
}
54+
}
55+
}
56+
}
57+
58+
& .terminal-output-scroll-container {
59+
height: 47vh;
60+
overflow: hidden;
61+
width: 100%;
62+
resize: none;
63+
white-space: pre-wrap;
64+
background-color: transparent;
65+
}
2966
}
3067

68+
3169
.btn-copy-output {
32-
padding: 1rem;
70+
padding: 0 1rem;
3371
border: none;
3472
cursor: pointer;
35-
position: absolute;
36-
right: 0;
3773
background: transparent;
3874
z-index: 1;
39-
border-radius: $border-radius;
75+
border-radius: $border-radius-sm;
4076
&:hover,
4177
&:focus-visible {
4278
outline: none;
@@ -53,6 +89,5 @@
5389
@include color-mode(dark) {
5490
.terminal-output {
5591
background-color: $body-bg-dark;
56-
border: $input-border-width solid $input-border-color;
5792
}
5893
}

apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.tsx

Lines changed: 182 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react';
2-
import { ButtonGroup, Form, InputGroup, Modal } from 'react-bootstrap';
31
import './SQLTerminal.scss';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
3+
import { ButtonGroup, Form, InputGroup, Modal, Table } from 'react-bootstrap';
44
import PerfectScrollbar from 'react-perfect-scrollbar';
55
import { CloseSVG } from '../../../svgs/Close';
66
import logger from '../../../services/logger.service';
@@ -11,15 +11,64 @@ import { RootService } from '../../../services/http.service';
1111
import { setShowModals, setShowToast } from '../../../store/rootSlice';
1212
import { useDispatch, useSelector } from 'react-redux';
1313
import { selectShowModals } from '../../../store/rootSelectors';
14+
import ToggleSwitch from '../../shared/ToggleSwitch/ToggleSwitch';
15+
16+
const parseTableName = (sql: string): string | null => {
17+
const match = sql.replace(/\s+/g, ' ').trim().match(/\bFROM\s+([`"\[]?[\w]+[`"\]]?)/i);
18+
if (!match) return null;
19+
return match[1].replace(/[`"[\]]/g, '');
20+
};
21+
22+
const parseColumnNames = (sql: string): string[] | null => {
23+
const trimmed = sql.replace(/\s+/g, ' ').trim();
24+
// Match everything between SELECT and FROM
25+
const match = trimmed.match(/^SELECT\s+([\s\S]+?)\s+FROM\s+/i);
26+
if (!match) return null;
27+
28+
const colsPart = match[1].trim();
29+
if (colsPart === '*') return null;
30+
31+
const cols: string[] = [];
32+
let depth = 0;
33+
let current = '';
34+
for (const ch of colsPart) {
35+
if (ch === '(') depth++;
36+
else if (ch === ')') depth--;
37+
else if (ch === ',' && depth === 0) {
38+
cols.push(current.trim());
39+
current = '';
40+
continue;
41+
}
42+
current += ch;
43+
}
44+
if (current.trim()) cols.push(current.trim());
45+
46+
return cols.map(col => {
47+
const asMatch = col.match(/\bAS\s+([`"\[]?[\w]+[`"\]]?)\s*$/i);
48+
if (asMatch) return asMatch[1].replace(/[`"[\]]/g, '');
49+
const tokens = col.split(/\s+/);
50+
const last = tokens[tokens.length - 1].replace(/[`"[\]]/g, '');
51+
if (/^[\w]+$/.test(last)) return last;
52+
return col;
53+
});
54+
};
55+
56+
type ViewMode = 'Raw' | 'Table';
57+
58+
type OutputState =
59+
| { type: 'empty' }
60+
| { type: 'error'; message: string }
61+
| { type: 'result'; columns: string[] | null; rows: any[][] };
1462

1563
const SQLTerminal = () => {
1664
const containerRef = useRef<HTMLDivElement>(null);
1765
const showModals = useSelector(selectShowModals);
1866
const dispatch = useDispatch();
19-
const outputRef = useRef<HTMLPreElement | null>(null);
67+
const outputRef = useRef<HTMLDivElement | null>(null);
2068
const [executed, setExecuted] = useState(false);
2169
const [query, setQuery] = useState('');
22-
const [output, setOutput] = useState('');
70+
const [viewMode, setViewMode] = useState<ViewMode>('Raw');
71+
const [outputState, setOutputState] = useState<OutputState>({ type: 'empty' });
2372

2473
const scrollToBottom = () => {
2574
if (outputRef.current) {
@@ -56,14 +105,35 @@ const SQLTerminal = () => {
56105
const formattedQuery = query.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
57106
try {
58107
const result: any = await RootService.executeSql(formattedQuery);
59-
setOutput(JSON.stringify(result.rows, null, 2) + '\n\n');
60-
setOutput(formattedQuery + '\n' + JSON.stringify(result.rows, null, 2) + '\n\n');
61-
} catch (error: any) {
62-
if (error && error.message) {
63-
setOutput(formattedQuery + '\nError: ' + error.message);
64-
} else {
65-
setOutput(formattedQuery + '\nError: ' + error);
108+
const rows: any[][] = result.rows;
109+
110+
if (!Array.isArray(rows) || rows.length === 0) {
111+
setOutputState({ type: 'result', columns: null, rows: [] });
112+
return;
113+
}
114+
115+
let columns = parseColumnNames(formattedQuery);
116+
117+
if (!columns) {
118+
const tableName = parseTableName(formattedQuery);
119+
if (tableName) {
120+
try {
121+
const schema: any = await RootService.listSqlSchemas(tableName);
122+
const tableSchema = schema?.schemas?.[0];
123+
if (tableSchema?.columns && Array.isArray(tableSchema.columns)) {
124+
columns = tableSchema.columns.map((col: any) => col.name ?? String(col));
125+
}
126+
} catch (schemaError: any) {
127+
logger.error('Failed to fetch SQL schema for table: ' + tableName, schemaError);
128+
}
129+
}
66130
}
131+
setOutputState({ type: 'result', columns, rows });
132+
} catch (error: any) {
133+
setOutputState({
134+
type: 'error',
135+
message: error?.message ? error.message : String(error),
136+
});
67137
}
68138
}, [query]);
69139

@@ -73,11 +143,11 @@ const SQLTerminal = () => {
73143

74144
const handleClear = () => {
75145
setQuery('');
76-
setOutput('');
146+
setOutputState({ type: 'empty' });
147+
setViewMode('Raw');
77148
};
78149

79150
useEffect(() => {
80-
// Check if the last character is a newline and the character before it is a semicolon
81151
if (!executed && query.endsWith('\n') && query.trimEnd().endsWith(';')) {
82152
setExecuted(true);
83153
handleExecute();
@@ -86,11 +156,104 @@ const SQLTerminal = () => {
86156

87157
useEffect(() => {
88158
scrollToBottom();
89-
}, [output]);
159+
}, [outputState]);
90160

91161
const closeHandler = () => {
162+
handleClear();
92163
dispatch(setShowModals({ ...showModals, sqlTerminalModal: false }));
93-
}
164+
};
165+
166+
const getHeaders = (columns: string[] | null, firstRow: any[]): string[] => {
167+
if (columns && columns.length === firstRow.length) return columns;
168+
return firstRow.map((_, i) => `Col ${i}`);
169+
};
170+
171+
const renderOutput = () => {
172+
switch (outputState.type) {
173+
case 'empty':
174+
return (
175+
<div className='terminal-output text-muted'>
176+
<span>Results will appear here…</span>
177+
</div>
178+
);
179+
180+
case 'error':
181+
return (
182+
<pre className='terminal-output text-invalid'>
183+
<code>Error: {outputState.message}</code>
184+
</pre>
185+
);
186+
187+
case 'result': {
188+
const { columns, rows } = outputState;
189+
190+
if (rows.length === 0) {
191+
return (
192+
<div className='terminal-output text-muted'>
193+
<span>Query returned no rows.</span>
194+
</div>
195+
);
196+
}
197+
198+
const headers = getHeaders(columns, rows[0]);
199+
200+
return (
201+
<div>
202+
<div className='d-flex justify-content-end'>
203+
<ToggleSwitch className='mb-4' onChange={(changedIndex) => setViewMode(changedIndex === 0 ? 'Raw' : 'Table')} values={['Raw', 'Table']} selValue={viewMode} />
204+
</div>
205+
{viewMode === 'Table' ? (
206+
<div className='terminal-output terminal-table-wrapper text-valid' ref={outputRef}>
207+
<div className='terminal-table-header text-muted'>
208+
{rows.length} row{rows.length !== 1 ? 's' : ''}
209+
</div>
210+
<PerfectScrollbar options={{ suppressScrollX: false }}>
211+
<Table striped bordered hover size='sm' className='sql-result-table mb-0'>
212+
<thead>
213+
<tr>
214+
{headers.map((h, i) => (
215+
<th key={i}>{h}</th>
216+
))}
217+
</tr>
218+
</thead>
219+
<tbody>
220+
{rows.map((row, ri) => (
221+
<tr key={ri}>
222+
{(Array.isArray(row) ? row : [row]).map((cell, ci) => (
223+
<td key={ci}>
224+
{cell === null || cell === undefined
225+
? <span className='text-muted fst-italic'>null</span>
226+
: String(cell)}
227+
</td>
228+
))}
229+
</tr>
230+
))}
231+
</tbody>
232+
</Table>
233+
</PerfectScrollbar>
234+
</div>
235+
) : (
236+
<div className='terminal-output text-valid'>
237+
<div className='d-flex justify-content-between align-items-start'>
238+
<div className='text-muted'>
239+
{rows.length} record{rows.length !== 1 ? 's' : ''}
240+
</div>
241+
<button tabIndex={3} onClick={handleCopy} className='btn-copy-output'>
242+
<CopySVG id='Output' showTooltip={true} />
243+
</button>
244+
</div>
245+
<pre className='terminal-output-scroll-container' ref={outputRef as any}>
246+
<PerfectScrollbar>
247+
<code>{JSON.stringify(rows, null, 2)}</code>
248+
</PerfectScrollbar>
249+
</pre>
250+
</div>
251+
)}
252+
</div>
253+
);
254+
}
255+
}
256+
};
94257

95258
return (
96259
<Modal ref={containerRef} show={showModals.sqlTerminalModal} onHide={closeHandler} centered className='modal-xl' data-testid='sql-terminal'>
@@ -128,44 +291,15 @@ const SQLTerminal = () => {
128291
<CopySVG id='SQL Query' showTooltip={true} />
129292
</InputGroup.Text>
130293
</InputGroup>
131-
<div style={{ position: 'relative' }}>
132-
<button tabIndex={3} onClick={handleCopy} className='btn-copy-output'>
133-
<CopySVG id='Output' showTooltip={true} />
134-
</button>
135-
<pre
136-
className={
137-
'terminal-output ' + (output.includes('Error: ') ? 'text-invalid' : 'text-valid')
138-
}
139-
ref={outputRef}
140-
>
141-
<PerfectScrollbar>
142-
<code>{output}</code>
143-
</PerfectScrollbar>
144-
</pre>
145-
</div>
294+
{renderOutput()}
146295
<ButtonGroup className='btn-group-action mb-3'>
147-
<button
148-
tabIndex={4}
149-
type='button'
150-
className='btn-rounded bg-primary fs-6 me-4'
151-
onClick={handleExecute}
152-
>
296+
<button tabIndex={4} type='button' className='btn-rounded bg-primary fs-6 me-4' onClick={handleExecute}>
153297
Execute
154298
</button>
155-
<button
156-
tabIndex={5}
157-
type='button'
158-
className='btn-rounded bg-primary fs-6 me-4'
159-
onClick={handleClear}
160-
>
299+
<button tabIndex={5} type='button' className='btn-rounded bg-primary fs-6 me-4' onClick={handleClear}>
161300
Clear
162301
</button>
163-
<button
164-
tabIndex={6}
165-
type='button'
166-
className='btn-rounded bg-primary fs-6 me-4'
167-
onClick={handleHelp}
168-
>
302+
<button tabIndex={6} type='button' className='btn-rounded bg-primary fs-6 me-4' onClick={handleHelp}>
169303
Help
170304
</button>
171305
</ButtonGroup>

0 commit comments

Comments
 (0)