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]

Reply via email to