Skip to content

Commit 95e170e

Browse files
Display query results in Raw or Table view
1 parent 4a3a98c commit 95e170e

2 files changed

Lines changed: 224 additions & 54 deletions

File tree

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 & 47 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,63 @@ 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+
const match = trimmed.match(/^SELECT\s+([\s\S]+?)\s+FROM\s+/i);
25+
if (!match) return null;
26+
27+
const colsPart = match[1].trim();
28+
if (colsPart === '*') return null;
29+
30+
const cols: string[] = [];
31+
let depth = 0;
32+
let current = '';
33+
for (const ch of colsPart) {
34+
if (ch === '(') depth++;
35+
else if (ch === ')') depth--;
36+
else if (ch === ',' && depth === 0) {
37+
cols.push(current.trim());
38+
current = '';
39+
continue;
40+
}
41+
current += ch;
42+
}
43+
if (current.trim()) cols.push(current.trim());
44+
45+
return cols.map(col => {
46+
const asMatch = col.match(/\bAS\s+([`"\[]?[\w]+[`"\]]?)\s*$/i);
47+
if (asMatch) return asMatch[1].replace(/[`"[\]]/g, '');
48+
const tokens = col.split(/\s+/);
49+
const last = tokens[tokens.length - 1].replace(/[`"[\]]/g, '');
50+
if (/^[\w]+$/.test(last)) return last;
51+
return col;
52+
});
53+
};
54+
55+
type ViewMode = 'Raw' | 'Table';
56+
57+
type OutputState =
58+
| { type: 'empty' }
59+
| { type: 'error'; message: string }
60+
| { type: 'result'; columns: string[] | null; rows: any[][] };
1461

1562
const SQLTerminal = () => {
1663
const containerRef = useRef<HTMLDivElement>(null);
1764
const showModals = useSelector(selectShowModals);
1865
const dispatch = useDispatch();
19-
const outputRef = useRef<HTMLPreElement | null>(null);
66+
const outputRef = useRef<HTMLDivElement | null>(null);
2067
const [executed, setExecuted] = useState(false);
2168
const [query, setQuery] = useState('');
22-
const [output, setOutput] = useState('');
69+
const [viewMode, setViewMode] = useState<ViewMode>('Raw');
70+
const [outputState, setOutputState] = useState<OutputState>({ type: 'empty' });
2371

2472
const scrollToBottom = () => {
2573
if (outputRef.current) {
@@ -56,14 +104,35 @@ const SQLTerminal = () => {
56104
const formattedQuery = query.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
57105
try {
58106
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);
107+
const rows: any[][] = result.rows;
108+
109+
if (!Array.isArray(rows) || rows.length === 0) {
110+
setOutputState({ type: 'result', columns: null, rows: [] });
111+
return;
112+
}
113+
114+
let columns = parseColumnNames(formattedQuery);
115+
116+
if (!columns) {
117+
const tableName = parseTableName(formattedQuery);
118+
if (tableName) {
119+
try {
120+
const schema: any = await RootService.listSqlSchemas(tableName);
121+
const tableSchema = schema?.schemas?.[0];
122+
if (tableSchema?.columns && Array.isArray(tableSchema.columns)) {
123+
columns = tableSchema.columns.map((col: any) => col.name ?? String(col));
124+
}
125+
} catch (schemaError: any) {
126+
logger.error('Failed to fetch SQL schema for table: ' + tableName, schemaError);
127+
}
128+
}
66129
}
130+
setOutputState({ type: 'result', columns, rows });
131+
} catch (error: any) {
132+
setOutputState({
133+
type: 'error',
134+
message: error?.message ? error.message : String(error),
135+
});
67136
}
68137
}, [query]);
69138

@@ -73,11 +142,11 @@ const SQLTerminal = () => {
73142

74143
const handleClear = () => {
75144
setQuery('');
76-
setOutput('');
145+
setOutputState({ type: 'empty' });
146+
setViewMode('Raw');
77147
};
78148

79149
useEffect(() => {
80-
// Check if the last character is a newline and the character before it is a semicolon
81150
if (!executed && query.endsWith('\n') && query.trimEnd().endsWith(';')) {
82151
setExecuted(true);
83152
handleExecute();
@@ -86,11 +155,101 @@ const SQLTerminal = () => {
86155

87156
useEffect(() => {
88157
scrollToBottom();
89-
}, [output]);
158+
}, [outputState]);
90159

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

95254
return (
96255
<Modal ref={containerRef} show={showModals.sqlTerminalModal} onHide={closeHandler} centered className='modal-xl' data-testid='sql-terminal'>
@@ -128,44 +287,20 @@ const SQLTerminal = () => {
128287
<CopySVG id='SQL Query' showTooltip={true} />
129288
</InputGroup.Text>
130289
</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>
290+
<div className='d-flex justify-content-end'>
291+
<ToggleSwitch className='mb-4' onChange={(changedIndex) => setViewMode(changedIndex === 0 ? 'Raw' : 'Table')} values={['Raw', 'Table']} selValue={viewMode} />
292+
</div>
293+
<div className='terminal-output-container'>
294+
{renderOutput()}
145295
</div>
146296
<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-
>
297+
<button tabIndex={4} type='button' className='btn-rounded bg-primary fs-6 me-4' onClick={handleExecute}>
153298
Execute
154299
</button>
155-
<button
156-
tabIndex={5}
157-
type='button'
158-
className='btn-rounded bg-primary fs-6 me-4'
159-
onClick={handleClear}
160-
>
300+
<button tabIndex={5} type='button' className='btn-rounded bg-primary fs-6 me-4' onClick={handleClear}>
161301
Clear
162302
</button>
163-
<button
164-
tabIndex={6}
165-
type='button'
166-
className='btn-rounded bg-primary fs-6 me-4'
167-
onClick={handleHelp}
168-
>
303+
<button tabIndex={6} type='button' className='btn-rounded bg-primary fs-6 me-4' onClick={handleHelp}>
169304
Help
170305
</button>
171306
</ButtonGroup>

0 commit comments

Comments
 (0)