This is an automated email from the ASF dual-hosted git repository.
ankitsultana pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 5f9241c05ff [timeseries] Introduced query options and explain plan
support for Controller UI (#17511)
5f9241c05ff is described below
commit 5f9241c05ff03a3297c373c37726f2cb5e99c51a
Author: Shaurya Chaturvedi <[email protected]>
AuthorDate: Fri Jan 16 14:24:18 2026 -0800
[timeseries] Introduced query options and explain plan support for
Controller UI (#17511)
* [timeseries] Added query options and explain plan support for Controller
UI
* Added query options documentation link to query options tooltip
---
.../requesthandler/TimeSeriesRequestHandler.java | 2 +-
.../api/resources/PinotQueryResource.java | 17 +-
.../app/components/Query/TimeseriesQueryPage.tsx | 355 ++++++++++++++++-----
.../main/resources/app/components/TableToolbar.tsx | 4 +-
.../tests/TimeSeriesIntegrationTest.java | 6 +-
5 files changed, 290 insertions(+), 94 deletions(-)
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/TimeSeriesRequestHandler.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/TimeSeriesRequestHandler.java
index 0106ab98041..067443a671f 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/TimeSeriesRequestHandler.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/TimeSeriesRequestHandler.java
@@ -406,7 +406,7 @@ public class TimeSeriesRequestHandler extends
BaseBrokerRequestHandler {
RangeTimeSeriesRequest request = buildRangeTimeSeriesRequest(lang,
rawQueryParamString, queryParams);
TimeSeriesLogicalPlanResult planResult =
_queryEnvironment.buildLogicalPlan(request);
String plan = explainPlanTree(planResult.getPlanNode(), new
StringBuilder(), 0).toString();
- DataSchema schema = new DataSchema(new String[]{"SQL", "PLAN"},
+ DataSchema schema = new DataSchema(new String[]{"QUERY", "PLAN"},
new DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.STRING,
DataSchema.ColumnDataType.STRING});
BrokerResponseNative response = BrokerResponseNative.empty();
response.setResultTable(new ResultTable(schema,
Collections.singletonList(new Object[]{request.getQuery(),
diff --git
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
index 3188693f84d..0793366979e 100644
---
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
+++
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
@@ -181,6 +181,7 @@ public class PinotQueryResource {
String start = requestJson.has("start") ?
requestJson.get("start").asText() : null;
String end = requestJson.has("end") ? requestJson.get("end").asText() :
null;
String step = requestJson.has("step") ? requestJson.get("step").asText()
: null;
+ String mode = requestJson.has("mode") ? requestJson.get("mode").asText()
: null;
Map<String, String> queryOptions = new HashMap<>();
if (requestJson.has("queryOptions") &&
requestJson.get("queryOptions").isObject()) {
@@ -189,7 +190,7 @@ public class PinotQueryResource {
});
}
- return executeTimeSeriesQueryCatching(httpHeaders, language, query,
start, end, step, queryOptions, true);
+ return executeTimeSeriesQueryCatching(httpHeaders, language, query,
start, end, step, queryOptions, mode, true);
} catch (Exception e) {
LOGGER.error("Caught exception while processing POST timeseries
request", e);
return constructQueryExceptionResponse(QueryErrorCode.INTERNAL,
e.getMessage());
@@ -695,17 +696,18 @@ public class PinotQueryResource {
private StreamingOutput executeTimeSeriesQueryCatching(HttpHeaders
httpHeaders, String language, String query,
String start, String end, String step) {
- return executeTimeSeriesQueryCatching(httpHeaders, language, query, start,
end, step, Map.of(), false);
+ return executeTimeSeriesQueryCatching(httpHeaders, language, query, start,
end, step, Map.of(), null, false);
}
private StreamingOutput executeTimeSeriesQueryCatching(HttpHeaders
httpHeaders, String language, String query,
- String start, String end, String step, Map<String, String> queryOptions,
boolean useBrokerCompatibleApi) {
+ String start, String end, String step, Map<String, String> queryOptions,
String mode,
+ boolean useBrokerCompatibleApi) {
try {
LOGGER.debug("Language: {}, Query: {}, Start: {}, End: {}, Step: {},
UseBrokerAPI: {}",
language, query, start, end, step, useBrokerCompatibleApi);
String instanceId = retrieveBrokerForTimeSeriesQuery(query, language,
start, end);
- return sendTimeSeriesRequestToBroker(language, query, start, end, step,
queryOptions, instanceId, httpHeaders,
- useBrokerCompatibleApi);
+ return sendTimeSeriesRequestToBroker(language, query, start, end, step,
queryOptions, mode, instanceId,
+ httpHeaders, useBrokerCompatibleApi);
} catch (QueryException ex) {
LOGGER.warn("Caught exception while processing timeseries request {}",
ex.getMessage());
return constructQueryExceptionResponse(ex.getErrorCode(),
ex.getMessage());
@@ -732,7 +734,7 @@ public class PinotQueryResource {
private StreamingOutput sendTimeSeriesRequestToBroker(String language,
String query, String start, String end,
- String step, Map<String, String> queryOptions, String instanceId,
HttpHeaders httpHeaders,
+ String step, Map<String, String> queryOptions, String mode, String
instanceId, HttpHeaders httpHeaders,
boolean useBrokerCompatibleApi) {
InstanceConfig instanceConfig = getInstanceConfig(instanceId);
String hostName = getHost(instanceConfig);
@@ -759,6 +761,9 @@ public class PinotQueryResource {
if (step != null && !step.isEmpty()) {
requestJson.put("step", step);
}
+ if (mode != null && !mode.isEmpty()) {
+ requestJson.put("mode", mode);
+ }
if (queryOptions != null && !queryOptions.isEmpty()) {
ObjectNode queryOptionsNode = JsonUtils.newObjectNode();
queryOptions.forEach(queryOptionsNode::put);
diff --git
a/pinot-controller/src/main/resources/app/components/Query/TimeseriesQueryPage.tsx
b/pinot-controller/src/main/resources/app/components/Query/TimeseriesQueryPage.tsx
index 7e945070f3a..aeb41ffbba0 100644
---
a/pinot-controller/src/main/resources/app/components/Query/TimeseriesQueryPage.tsx
+++
b/pinot-controller/src/main/resources/app/components/Query/TimeseriesQueryPage.tsx
@@ -21,16 +21,15 @@ import React, { useState, useEffect, useCallback } from
'react';
import {
Grid,
FormControl,
- InputLabel,
Select,
MenuItem,
Typography,
makeStyles,
Button,
Input,
- FormControlLabel,
ButtonGroup,
- Box
+ Box,
+ Checkbox
} from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import FileCopyIcon from '@material-ui/icons/FileCopy';
@@ -51,6 +50,18 @@ import { DEFAULT_SERIES_LIMIT } from
'../../utils/ChartConstants';
import CustomizedTables from '../Table';
import PinotMethodUtils from '../../utils/PinotMethodUtils';
+// Constants
+const EDITOR_MIN_HEIGHT = 220;
+const EDITOR_DEFAULT_WIDTH_PERCENT = 75;
+const EDITOR_MIN_WIDTH_PERCENT = 30;
+const EDITOR_MAX_WIDTH_PERCENT = 80;
+const CONTROL_INPUT_HEIGHT = 36;
+
+// Helper functions (outside component to avoid recreation)
+const getCurrentTimestamp = () => Math.floor(Date.now() / 1000).toString();
+const getOneMinuteAgoTimestamp = () => Math.floor((Date.now() - 60 * 1000) /
1000).toString();
+const isMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
+
interface CodeMirrorEditor {
getValue: () => string;
setValue: (value: string) => void;
@@ -97,6 +108,32 @@ const useStyles = makeStyles((theme) => ({
checkBox: {
margin: '20px 0',
},
+ queryControlsRow: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'stretch',
+ margin: '20px 0',
+ },
+ queryControlItem: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ },
+ queryControlLabel: {
+ fontSize: '0.875rem',
+ color: 'rgba(0, 0, 0, 0.54)',
+ marginBottom: theme.spacing(1),
+ whiteSpace: 'nowrap',
+ },
+ queryControlInput: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ editorRow: {
+ display: 'flex',
+ width: '100%',
+ },
actionBtns: {
margin: '20px 0',
height: 50,
@@ -112,6 +149,35 @@ const useStyles = makeStyles((theme) => ({
marginBottom: '20px',
paddingBottom: '48px',
},
+ queryOptionsPanel: {
+ flex: 1,
+ marginLeft: theme.spacing(1),
+ minWidth: '18%',
+ },
+ controlSelect: {
+ minWidth: 120,
+ height: CONTROL_INPUT_HEIGHT,
+ },
+ controlInputStart: {
+ minWidth: 130,
+ },
+ controlInputEnd: {
+ minWidth: 130,
+ },
+ controlInputTimeout: {
+ minWidth: 80,
+ },
+ controlInputCheckbox: {
+ height: CONTROL_INPUT_HEIGHT,
+ display: 'flex',
+ alignItems: 'center',
+ },
+ controlButton: {
+ height: CONTROL_INPUT_HEIGHT,
+ },
+ queryControlLabelHidden: {
+ visibility: 'hidden',
+ },
sqlError: {
whiteSpace: 'pre',
overflow: "auto"
@@ -173,10 +239,11 @@ const ViewToggle: React.FC<{
viewType: 'json' | 'chart';
onViewChange: (view: 'json' | 'chart') => void;
isChartDisabled: boolean;
+ isExplainMode: boolean;
onCopy: () => void;
copyMsg: boolean;
classes: ReturnType<typeof useStyles>;
-}> = ({ viewType, onViewChange, isChartDisabled, onCopy, copyMsg, classes })
=> (
+}> = ({ viewType, onViewChange, isChartDisabled, isExplainMode, onCopy,
copyMsg, classes }) => (
<Grid container className={classes.actionBtns} alignItems="center"
justify="space-between">
<Grid item>
<ButtonGroup color="primary" size="small">
@@ -185,7 +252,7 @@ const ViewToggle: React.FC<{
variant={viewType === 'chart' ? "contained" : "outlined"}
disabled={isChartDisabled}
>
- Chart
+ {isExplainMode ? 'Result' : 'Chart'}
</Button>
<Button
onClick={() => onViewChange('json')}
@@ -234,6 +301,8 @@ interface TimeseriesQueryConfig {
startTime: string;
endTime: string;
timeout: number;
+ queryOptions: string;
+ explainPlan: boolean;
}
const TimeseriesQueryPage = () => {
@@ -241,15 +310,14 @@ const TimeseriesQueryPage = () => {
const history = useHistory();
const location = useLocation();
- const getCurrentTimestamp = () => Math.floor(Date.now() / 1000).toString();
- const getOneMinuteAgoTimestamp = () => Math.floor((Date.now() - 60 * 1000) /
1000).toString();
-
const [config, setConfig] = useState<TimeseriesQueryConfig>({
queryLanguage: 'm3ql',
query: '',
startTime: getOneMinuteAgoTimestamp(),
endTime: getCurrentTimestamp(),
timeout: 60000,
+ queryOptions: '',
+ explainPlan: false,
});
const [supportedLanguages, setSupportedLanguages] =
useState<Array<string>>([]);
@@ -265,11 +333,18 @@ const TimeseriesQueryPage = () => {
const [viewType, setViewType] = useState<'json' | 'chart'>('chart');
const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
const [seriesLimitInput, setSeriesLimitInput] =
useState<string>(DEFAULT_SERIES_LIMIT.toString());
+ const [editorHeight, setEditorHeight] = useState<number>(EDITOR_MIN_HEIGHT);
+ const [editorWidthPercent, setEditorWidthPercent] =
useState<number>(EDITOR_DEFAULT_WIDTH_PERCENT);
+ const editorContainerRef = React.useRef<HTMLDivElement | null>(null);
+ const [resultTable, setResultTable] = useState<TableData>({
+ columns: [],
+ records: [],
+ });
const [queryStats, setQueryStats] = useState<TableData>({
columns: [],
records: [],
});
-
+ const [executedExplainMode, setExecutedExplainMode] =
useState<boolean>(false);
// Fetch supported languages from controller configuration
useEffect(() => {
@@ -299,6 +374,8 @@ const TimeseriesQueryPage = () => {
startTime: urlParams.get('start') || getOneMinuteAgoTimestamp(),
endTime: urlParams.get('end') || getCurrentTimestamp(),
timeout: parseInt(urlParams.get('timeout') || '60000'),
+ queryOptions: urlParams.get('queryOptions') || '',
+ explainPlan: urlParams.get('mode') === 'explain',
};
setConfig(newConfig);
@@ -326,6 +403,12 @@ const TimeseriesQueryPage = () => {
if (newConfig.timeout && newConfig.timeout !== 60000) {
params.set('timeout', newConfig.timeout.toString());
}
+ if (newConfig.queryOptions && newConfig.queryOptions.trim().length > 0) {
+ params.set('queryOptions', newConfig.queryOptions.trim());
+ }
+ if (newConfig.explainPlan) {
+ params.set('mode', 'explain');
+ }
const newURL = params.toString() ? `?${params.toString()}` : '';
history.push({
@@ -342,6 +425,10 @@ const TimeseriesQueryPage = () => {
setConfig(prev => ({ ...prev, query: value }));
};
+ const handleQueryOptionsChange = (editor: CodeMirrorEditor, data:
CodeMirrorChangeData, value: string) => {
+ setConfig(prev => ({ ...prev, queryOptions: value }));
+ };
+
const handleQueryInterfaceKeyDown = (editor: CodeMirrorEditor, event:
KeyboardEvent) => {
const modifiedEnabled = event.metaKey == true || event.ctrlKey == true;
@@ -363,6 +450,25 @@ const TimeseriesQueryPage = () => {
return;
}
+ let queryOptionsObject: Record<string, string | number | boolean> = {};
+ if (config.queryOptions.trim().length > 0) {
+ try {
+ const parsedOptions = JSON.parse(config.queryOptions);
+ if (parsedOptions && typeof parsedOptions === 'object' &&
!Array.isArray(parsedOptions)) {
+ queryOptionsObject = parsedOptions;
+ } else {
+ setError('Query options must be a JSON object');
+ return;
+ }
+ } catch (parseError) {
+ setError('Query options must be valid JSON');
+ return;
+ }
+ }
+ if (config.timeout && queryOptionsObject.timeoutMs === undefined) {
+ queryOptionsObject.timeoutMs = config.timeout;
+ }
+
updateURL(config);
setIsLoading(true);
setError('');
@@ -371,6 +477,7 @@ const TimeseriesQueryPage = () => {
setChartSeries([]);
setTotalSeriesCount(0);
setQueryStats({ columns: [], records: [] });
+ setResultTable({ columns: [], records: [] });
try {
const requestPayload = {
@@ -380,7 +487,8 @@ const TimeseriesQueryPage = () => {
end: config.endTime,
step: '1m',
trace: false,
- queryOptions: `timeoutMs=${config.timeout}`
+ queryOptions: queryOptionsObject,
+ ...(config.explainPlan ? { mode: 'explain' } : {}),
};
// Call the API and process with the shared broker response processor
@@ -391,6 +499,7 @@ const TimeseriesQueryPage = () => {
// Set query stats (already extracted by the utility)
setQueryStats(results.queryStats || { columns: [], records: [] });
+ setResultTable(results.result || { columns: [], records: [] });
// Handle exceptions/errors
if (results.exceptions && results.exceptions.length > 0) {
@@ -416,6 +525,9 @@ const TimeseriesQueryPage = () => {
setChartSeries([]);
setTotalSeriesCount(0);
}
+
+ // Set the executed explain mode state after successful query
+ setExecutedExplainMode(config.explainPlan);
} catch (error) {
console.error('Error executing timeseries query:', error);
const errorMessage = error.response?.data?.message || error.message ||
'Unknown error occurred';
@@ -448,25 +560,73 @@ const TimeseriesQueryPage = () => {
)}
<Grid item xs={12} className={classes.rightPanel}>
- <Resizable
- defaultSize={{ width: '100%', height: 148 }}
- minHeight={148}
- maxWidth={'100%'}
- maxHeight={'50vh'}
- enable={{bottom: true}}>
- <div className={classes.sqlDiv}>
+ <div className={classes.editorRow} ref={editorContainerRef}>
+ <Resizable
+ size={{ width: `${editorWidthPercent}%`, height: editorHeight }}
+ minHeight={EDITOR_MIN_HEIGHT}
+ minWidth={`${EDITOR_MIN_WIDTH_PERCENT}%`}
+ maxWidth={`${EDITOR_MAX_WIDTH_PERCENT}%`}
+ maxHeight={'50vh'}
+ enable={{ bottom: true, right: true, bottomRight: true }}
+ onResize={(event, direction, ref) => {
+ setEditorHeight(ref.offsetHeight);
+ if (editorContainerRef.current && (direction === 'right' ||
direction === 'bottomRight')) {
+ const containerWidth = editorContainerRef.current.offsetWidth;
+ const newPercent = Math.min(EDITOR_MAX_WIDTH_PERCENT,
Math.max(EDITOR_MIN_WIDTH_PERCENT, (ref.offsetWidth / containerWidth) * 100));
+ setEditorWidthPercent(newPercent);
+ }
+ }}
+ >
+ <div className={classes.sqlDiv}>
+ <TableToolbar
+ name="Timeseries Query Editor"
+ showSearchBox={false}
+ showTooltip={true}
+ tooltipText="This editor supports timeseries queries. Enter
your M3QL query here."
+ />
+ <CodeMirror
+ value={config.query}
+ onChange={handleQueryChange}
+ options={{
+ lineNumbers: true,
+ mode: 'text/plain',
+ theme: 'default',
+ lineWrapping: true,
+ indentWithTabs: true,
+ smartIndent: true,
+ readOnly: supportedLanguages.length === 0,
+ }}
+ className={classes.codeMirror}
+ autoCursor={false}
+ onKeyDown={(editor, event) =>
handleQueryInterfaceKeyDownRef.current(editor, event)}
+ />
+ </div>
+ </Resizable>
+ <div className={`${classes.sqlDiv} ${classes.queryOptionsPanel}`}
style={{ height: editorHeight }}>
<TableToolbar
- name="Timeseries Query Editor"
+ name="Query Options (JSON)"
showSearchBox={false}
showTooltip={true}
- tooltipText="This editor supports timeseries queries. Enter your
M3QL query here."
+ tooltipText={
+ <span>
+ Enter query options as a JSON map of strings, e.g.
{`{"enableNullHandling": "true"}`}. Please find the list of supported query
options in the{' '}
+ <a
+
href="https://docs.pinot.apache.org/users/user-guide-query/query-options"
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{ color: '#90caf9', textDecoration: 'underline' }}
+ >
+ documentation
+ </a>.
+ </span>
+ }
/>
<CodeMirror
- value={config.query}
- onChange={handleQueryChange}
+ value={config.queryOptions}
+ onChange={handleQueryOptionsChange}
options={{
lineNumbers: true,
- mode: 'text/plain',
+ mode: 'application/json',
theme: 'default',
lineWrapping: true,
indentWithTabs: true,
@@ -475,19 +635,20 @@ const TimeseriesQueryPage = () => {
}}
className={classes.codeMirror}
autoCursor={false}
- onKeyDown={(editor, event) =>
handleQueryInterfaceKeyDownRef.current(editor, event)}
/>
</div>
- </Resizable>
+ </div>
- <Grid container className={classes.checkBox} spacing={2}
alignItems="center" justify="space-between">
- <Grid item xs={12} sm={6} md={2} className={classes.gridItem}>
- <FormControl fullWidth={true} className={classes.formControl}>
- <InputLabel>Query Language</InputLabel>
+ <div className={classes.queryControlsRow}>
+ <div className={classes.queryControlItem}>
+ <Typography className={classes.queryControlLabel}>Query
Language</Typography>
+ <div className={classes.queryControlInput}>
<Select
value={config.queryLanguage}
onChange={(e) => handleConfigChange('queryLanguage',
e.target.value as string)}
disabled={languagesLoading || supportedLanguages.length === 0}
+ variant="outlined"
+ className={classes.controlSelect}
>
{supportedLanguages.map((lang) => (
<MenuItem key={lang} value={lang}>
@@ -495,89 +656,119 @@ const TimeseriesQueryPage = () => {
</MenuItem>
))}
</Select>
- </FormControl>
- </Grid>
+ </div>
+ </div>
- <Grid item xs={12} md={2} className={classes.gridItem}>
- <FormControl fullWidth={true} className={classes.formControl}>
- <InputLabel>Start Time (Unix timestamp)</InputLabel>
+ <div className={classes.queryControlItem}>
+ <Typography className={classes.queryControlLabel}>Start Time
(Unix)</Typography>
+ <div className={classes.queryControlInput}>
<Input
type="text"
value={config.startTime}
onChange={(e) => handleConfigChange('startTime',
e.target.value as string)}
placeholder={getOneMinuteAgoTimestamp()}
disabled={supportedLanguages.length === 0}
+ className={classes.controlInputStart}
/>
- </FormControl>
- </Grid>
+ </div>
+ </div>
- <Grid item xs={12} md={2} className={classes.gridItem}>
- <FormControl fullWidth={true} className={classes.formControl}>
- <InputLabel>End Time (Unix timestamp)</InputLabel>
+ <div className={classes.queryControlItem}>
+ <Typography className={classes.queryControlLabel}>End Time
(Unix)</Typography>
+ <div className={classes.queryControlInput}>
<Input
type="text"
value={config.endTime}
onChange={(e) => handleConfigChange('endTime', e.target.value
as string)}
placeholder={getCurrentTimestamp()}
disabled={supportedLanguages.length === 0}
+ className={classes.controlInputEnd}
/>
- </FormControl>
- </Grid>
+ </div>
+ </div>
- <Grid item xs={12} sm={6} md={2} className={classes.gridItem}>
- <FormControl fullWidth={true} className={classes.formControl}>
- <InputLabel>Timeout (Milliseconds)</InputLabel>
+ <div className={classes.queryControlItem}>
+ <Typography className={classes.queryControlLabel}>Timeout
(ms)</Typography>
+ <div className={classes.queryControlInput}>
<Input
type="text"
value={config.timeout}
onChange={(e) => handleConfigChange('timeout',
parseInt(e.target.value as string) || 60000)}
disabled={supportedLanguages.length === 0}
+ className={classes.controlInputTimeout}
/>
- </FormControl>
- </Grid>
+ </div>
+ </div>
- <Grid item xs={12} sm={12} md={2} className={classes.gridItem}
style={{ display: 'flex', justifyContent: 'center' }}>
- <Button
- variant="contained"
- color="primary"
- onClick={handleExecuteQuery}
- disabled={isLoading || !config.query.trim() ||
supportedLanguages.length === 0}
- endIcon={<span style={{fontSize: '0.8em', lineHeight:
1}}>{navigator.platform.includes('Mac') ? '⌘↵' : 'Ctrl+↵'}</span>}
- >
- {isLoading ? 'Running Query...' : 'Run Query'}
- </Button>
- </Grid>
- </Grid>
+ <div className={classes.queryControlItem}>
+ <Typography className={classes.queryControlLabel}>Explain
Plan</Typography>
+ <div className={`${classes.queryControlInput}
${classes.controlInputCheckbox}`}>
+ <Checkbox
+ checked={config.explainPlan}
+ onChange={(e) => handleConfigChange('explainPlan',
e.target.checked)}
+ color="primary"
+ size="small"
+ disabled={supportedLanguages.length === 0}
+ />
+ </div>
+ </div>
+
+ <div className={classes.queryControlItem}>
+ <Typography className={`${classes.queryControlLabel}
${classes.queryControlLabelHidden}`}>Run</Typography>
+ <div className={classes.queryControlInput}>
+ <Button
+ variant="contained"
+ color="primary"
+ onClick={handleExecuteQuery}
+ disabled={isLoading || !config.query.trim() ||
supportedLanguages.length === 0}
+ endIcon={<span style={{ fontSize: '0.8em', lineHeight: 1
}}>{isMac() ? '⌘↵' : 'Ctrl+↵'}</span>}
+ className={classes.controlButton}
+ >
+ {isLoading ? 'Running...' : 'Run Query'}
+ </Button>
+ </div>
+ </div>
+ </div>
{queryStats.columns.length > 0 && (
- <Grid item xs style={{ backgroundColor: 'white' }}>
- <CustomizedTables
- title="Query Response Stats"
- data={queryStats}
- showSearchBox={true}
- inAccordionFormat={true}
- />
- </Grid>
- )}
+ <Grid item xs style={{ backgroundColor: 'white' }}>
+ <CustomizedTables
+ title="Query Response Stats"
+ data={queryStats}
+ showSearchBox={true}
+ inAccordionFormat={true}
+ />
+ </Grid>
+ )}
{rawOutput && (
<Grid item xs style={{ backgroundColor: 'white' }}>
- <ViewToggle
- viewType={viewType}
- onViewChange={setViewType}
- isChartDisabled={chartSeries.length === 0}
- onCopy={copyToClipboard}
- copyMsg={copyMsg}
- classes={classes}
- />
-
- {error && (
- <Alert severity="error" className={classes.sqlError}>
- {error}
- </Alert>
- )}
-
- {viewType === 'chart' && (
+ <ViewToggle
+ viewType={viewType}
+ onViewChange={setViewType}
+ isChartDisabled={executedExplainMode ? false :
chartSeries.length === 0}
+ isExplainMode={executedExplainMode}
+ onCopy={copyToClipboard}
+ copyMsg={copyMsg}
+ classes={classes}
+ />
+
+ {error && (
+ <Alert severity="error" className={classes.sqlError}>
+ {error}
+ </Alert>
+ )}
+
+ {viewType === 'chart' && executedExplainMode && (
+ <CustomizedTables
+ title="Query Result"
+ data={resultTable}
+ showSearchBox={true}
+ inAccordionFormat={true}
+ />
+ )}
+
+ {viewType === 'chart' && !executedExplainMode && (
<SimpleAccordion
headerTitle="Timeseries Chart & Statistics"
showSearchBox={false}
diff --git
a/pinot-controller/src/main/resources/app/components/TableToolbar.tsx
b/pinot-controller/src/main/resources/app/components/TableToolbar.tsx
index f0060b20110..e37e5b8d19b 100644
--- a/pinot-controller/src/main/resources/app/components/TableToolbar.tsx
+++ b/pinot-controller/src/main/resources/app/components/TableToolbar.tsx
@@ -32,7 +32,7 @@ type Props = {
handleSearch?: Function;
recordCount?: number;
showTooltip?: boolean;
- tooltipText?: string;
+ tooltipText?: React.ReactNode;
additionalControls?: React.ReactNode;
};
@@ -100,7 +100,7 @@ export default function TableToolbar({
)}
{showTooltip && (
- <Tooltip title={tooltipText}>
+ <Tooltip title={tooltipText} interactive>
<HelpOutlineIcon />
</Tooltip>
)}
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TimeSeriesIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TimeSeriesIntegrationTest.java
index c3e615abbca..60b6660671d 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TimeSeriesIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TimeSeriesIntegrationTest.java
@@ -320,15 +320,15 @@ public class TimeSeriesIntegrationTest extends
BaseClusterIntegrationTest {
JsonNode dataSchema = resultTable.path("dataSchema");
JsonNode columnNames = dataSchema.path("columnNames");
- assertEquals(columnNames.size(), 2, "Should have SQL and PLAN columns");
- assertEquals(columnNames.get(0).asText(), "SQL");
+ assertEquals(columnNames.size(), 2, "Should have QUERY and PLAN columns");
+ assertEquals(columnNames.get(0).asText(), "QUERY");
assertEquals(columnNames.get(1).asText(), "PLAN");
JsonNode rows = resultTable.path("rows");
assertEquals(rows.size(), 1, "Should have exactly one row");
JsonNode row = rows.get(0);
- assertEquals(row.get(0).asText(), query, "SQL should match the original
query");
+ assertEquals(row.get(0).asText(), query, "QUERY should match the original
query");
// Verify PLAN matches expected (normalize dynamic plan IDs like "plan_2,
")
String actualPlan = row.get(1).asText().replaceAll("plan_\\d+, ", "");
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]