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 ec97c6c3bd [timeseries] Introducing Apache ECharts timeseries results 
visualization in Controller UI (#16390)
ec97c6c3bd is described below

commit ec97c6c3bdf6b92926d458f9cf45914422bc1116
Author: Shaurya Chaturvedi <[email protected]>
AuthorDate: Tue Jul 22 09:10:37 2025 -0700

    [timeseries] Introducing Apache ECharts timeseries results visualization in 
Controller UI (#16390)
    
    Co-authored-by: Shaurya Chaturvedi <[email protected]>
---
 .../app/components/Query/MetricStatsTable.tsx      | 212 +++++++++++++++++
 .../app/components/Query/TimeseriesChart.tsx       | 255 +++++++++++++++++++++
 .../app/components/Query/TimeseriesQueryPage.tsx   | 238 +++++++++++++++----
 .../src/main/resources/app/interfaces/types.d.ts   |  52 ++++-
 .../src/main/resources/app/utils/ChartConstants.ts |  45 ++++
 .../main/resources/app/utils/TimeseriesUtils.ts    | 161 +++++++++++++
 .../src/main/resources/package-lock.json           | 104 ++++++++-
 pinot-controller/src/main/resources/package.json   |   2 +
 8 files changed, 1021 insertions(+), 48 deletions(-)

diff --git 
a/pinot-controller/src/main/resources/app/components/Query/MetricStatsTable.tsx 
b/pinot-controller/src/main/resources/app/components/Query/MetricStatsTable.tsx
new file mode 100644
index 0000000000..0648950b27
--- /dev/null
+++ 
b/pinot-controller/src/main/resources/app/components/Query/MetricStatsTable.tsx
@@ -0,0 +1,212 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableContainer,
+  TableHead,
+  TableRow,
+  Paper,
+  Typography,
+  makeStyles,
+} from '@material-ui/core';
+
+import { ChartSeries } from 'Models';
+import { getSeriesColor } from '../../utils/ChartConstants';
+
+const useStyles = makeStyles((theme) => ({
+  tableContainer: {
+    marginTop: theme.spacing(2),
+    maxHeight: 400,
+  },
+  table: {
+    minWidth: 650,
+    tableLayout: 'fixed',
+  },
+  tableHead: {
+    backgroundColor: theme.palette.grey[100],
+  },
+  tableHeadCell: {
+    color: theme.palette.text.primary,
+    fontSize: '0.875rem',
+  },
+  tableRow: {
+    cursor: 'pointer',
+    '&:hover': {
+      backgroundColor: theme.palette.action.hover,
+    },
+    '&.selected': {
+      backgroundColor: theme.palette.primary.light,
+      color: theme.palette.primary.contrastText,
+    },
+    '&.dimmed': {
+      opacity: 0.4,
+      backgroundColor: theme.palette.grey[50],
+    },
+  },
+  metricCell: {
+    maxWidth: 300,
+    wordBreak: 'break-word',
+    fontSize: '0.875rem',
+  },
+  statCell: {
+    fontSize: '0.875rem',
+  },
+  colorBox: {
+    width: 10,
+    height: 10,
+    borderRadius: 2,
+    flexShrink: 0,
+    margin: '0 auto',
+  },
+
+  noDataMessage: {
+    textAlign: 'center',
+    padding: theme.spacing(3),
+    color: theme.palette.text.secondary,
+  },
+  tableTitle: {
+    marginBottom: theme.spacing(1),
+    fontWeight: 'bold',
+    fontSize: '1.1rem',
+  },
+}));
+
+interface MetricStatsTableProps {
+  series: ChartSeries[];
+  selectedMetric?: string;
+  onMetricSelect?: (metricName: string | null) => void;
+}
+
+const MetricStatsTable: React.FC<MetricStatsTableProps> = ({
+  series,
+  selectedMetric,
+  onMetricSelect
+}) => {
+  const classes = useStyles();
+
+  const formatValue = (value: number): string => {
+    if (Math.abs(value) >= 1e6) {
+      return (value / 1e6).toFixed(2) + 'M';
+    } else if (Math.abs(value) >= 1e3) {
+      return (value / 1e3).toFixed(2) + 'K';
+    } else {
+      return value.toFixed(2);
+    }
+  };
+
+
+
+  const handleRowClick = (metricName: string) => {
+    if (onMetricSelect) {
+      // If clicking the same metric, deselect it
+      if (selectedMetric === metricName) {
+        onMetricSelect(null);
+      } else {
+        onMetricSelect(metricName);
+      }
+    }
+  };
+
+  const isRowSelected = (metricName: string) => selectedMetric === metricName;
+  const isRowDimmed = (metricName: string) => selectedMetric && selectedMetric 
!== metricName;
+
+  if (!series || series.length === 0) {
+    return (
+      <div className={classes.noDataMessage}>
+        <Typography variant="h6">No metric data available</Typography>
+        <Typography variant="body2">Run a timeseries query to see 
statistics</Typography>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+
+      <TableContainer component={Paper} className={classes.tableContainer}>
+        <Table className={classes.table} size="small">
+          <TableHead className={classes.tableHead}>
+            <TableRow>
+              <TableCell className={classes.tableHeadCell} align="center" 
style={{ width: '40px', padding: '8px 4px' }}>Color</TableCell>
+              <TableCell className={classes.tableHeadCell}>Metric</TableCell>
+              <TableCell className={classes.tableHeadCell} 
align="right">Count</TableCell>
+              <TableCell className={classes.tableHeadCell} 
align="right">Min</TableCell>
+              <TableCell className={classes.tableHeadCell} 
align="right">Max</TableCell>
+              <TableCell className={classes.tableHeadCell} 
align="right">Average</TableCell>
+              <TableCell className={classes.tableHeadCell} 
align="right">Sum</TableCell>
+              <TableCell className={classes.tableHeadCell} align="right">First 
Value</TableCell>
+              <TableCell className={classes.tableHeadCell} align="right">Last 
Value</TableCell>
+            </TableRow>
+          </TableHead>
+          <TableBody>
+            {series.map((chartSeries) => {
+              const { name, stats } = chartSeries;
+              const isSelected = isRowSelected(name);
+              const isDimmed = isRowDimmed(name);
+
+              return (
+                <TableRow
+                  key={name}
+                  className={`${classes.tableRow} ${isSelected ? 'selected' : 
''} ${isDimmed ? 'dimmed' : ''}`}
+                  onClick={() => handleRowClick(name)}
+                >
+                  <TableCell align="center" style={{ width: '40px', padding: 
'8px 4px' }}>
+                    <div
+                      className={classes.colorBox}
+                      style={{ backgroundColor: 
getSeriesColor(series.findIndex(s => s.name === name)) }}
+                    />
+                  </TableCell>
+                  <TableCell className={classes.metricCell}>
+                    {name}
+                  </TableCell>
+                  <TableCell className={classes.statCell} align="right">
+                    {stats.count}
+                  </TableCell>
+                  <TableCell className={classes.statCell} align="right">
+                    {formatValue(stats.min)}
+                  </TableCell>
+                  <TableCell className={classes.statCell} align="right">
+                    {formatValue(stats.max)}
+                  </TableCell>
+                  <TableCell className={classes.statCell} align="right">
+                    {formatValue(stats.avg)}
+                  </TableCell>
+                  <TableCell className={classes.statCell} align="right">
+                    {formatValue(stats.sum)}
+                  </TableCell>
+                  <TableCell className={classes.statCell} align="right">
+                    {formatValue(stats.firstValue)}
+                  </TableCell>
+                  <TableCell className={classes.statCell} align="right">
+                    {formatValue(stats.lastValue)}
+                  </TableCell>
+                </TableRow>
+              );
+            })}
+          </TableBody>
+        </Table>
+      </TableContainer>
+    </div>
+  );
+};
+
+export default MetricStatsTable;
diff --git 
a/pinot-controller/src/main/resources/app/components/Query/TimeseriesChart.tsx 
b/pinot-controller/src/main/resources/app/components/Query/TimeseriesChart.tsx
new file mode 100644
index 0000000000..908f56c3e6
--- /dev/null
+++ 
b/pinot-controller/src/main/resources/app/components/Query/TimeseriesChart.tsx
@@ -0,0 +1,255 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useMemo } from 'react';
+import ReactECharts from 'echarts-for-react';
+import { makeStyles } from '@material-ui/core/styles';
+import { Typography, Paper } from '@material-ui/core';
+import { ChartSeries } from 'Models';
+import { getSeriesColor, MAX_SERIES_LIMIT, CHART_PADDING_PERCENTAGE } from 
'../../utils/ChartConstants';
+
+// Define proper types for ECharts parameters
+interface EChartsTooltipParams {
+  value: [number, number];
+  seriesName: string;
+  color: string;
+}
+
+// Extract chart configuration functions
+const createChartSeries = (series: ChartSeries[], selectedMetric?: string) => {
+  const limitedSeries = series.slice(0, MAX_SERIES_LIMIT);
+
+  return limitedSeries.map((s, index) => {
+    const isSelected = selectedMetric ? s.name === selectedMetric : true;
+
+    return {
+      name: s.name,
+      type: 'line',
+      data: s.data.map(dp => [dp.timestamp, dp.value]),
+      smooth: false,
+      symbol: 'circle',
+      symbolSize: isSelected ? 8 : 4,
+      lineStyle: {
+        width: 1,
+        color: getSeriesColor(index),
+        opacity: isSelected ? 1 : 0,
+      },
+      itemStyle: {
+        color: getSeriesColor(index),
+        opacity: isSelected ? 1 : 0,
+      },
+      emphasis: {
+        focus: 'none',
+        scale: false,
+      },
+    };
+  });
+};
+
+const createTooltipFormatter = (selectedMetric?: string) => {
+  return function (params: EChartsTooltipParams[]) {
+    let result = `<div style="font-weight: bold;">${new 
Date(params[0].value[0]).toLocaleString()}</div>`;
+
+    // Filter params to only show selected series when one is selected
+    const filteredParams = selectedMetric
+      ? params.filter((param: EChartsTooltipParams) => param.seriesName === 
selectedMetric)
+      : params;
+
+    filteredParams.forEach((param: EChartsTooltipParams) => {
+      const color = param.color;
+      const name = param.seriesName;
+      const value = param.value[1];
+      result += `<div style="margin: 5px 0;">
+        <span style="display: inline-block; width: 10px; height: 10px; 
background: ${color}; margin-right: 5px;"></span>
+        <span style="font-weight: bold;">${name}:</span> ${value}
+      </div>`;
+    });
+    return result;
+  };
+};
+
+const getTimeRange = (series: ChartSeries[]) => {
+  const limitedSeries = series.slice(0, MAX_SERIES_LIMIT);
+  const allTimestamps = limitedSeries.flatMap(s => s.data.map(dp => 
dp.timestamp));
+  const minTime = Math.min(...allTimestamps);
+  const maxTime = Math.max(...allTimestamps);
+  return { minTime, maxTime };
+};
+
+const useStyles = makeStyles((theme) => ({
+  chartContainer: {
+    height: '500px',
+    width: '100%',
+    padding: theme.spacing(2),
+  },
+  noDataMessage: {
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
+    height: '200px',
+    color: theme.palette.text.secondary,
+  },
+  chartTitle: {
+    marginBottom: theme.spacing(2),
+    fontWeight: 'bold',
+  },
+}));
+
+interface TimeseriesChartProps {
+  series: ChartSeries[];
+  height?: number;
+  selectedMetric?: string;
+}
+
+const TimeseriesChart: React.FC<TimeseriesChartProps> = ({
+  series,
+  height = 500,
+  selectedMetric
+}) => {
+  const classes = useStyles();
+
+  const chartOption = useMemo(() => {
+    if (!series || series.length === 0) {
+      return {};
+    }
+
+    const chartSeries = createChartSeries(series, selectedMetric);
+    const { minTime, maxTime } = getTimeRange(series);
+    const timeRange = maxTime - minTime;
+    const padding = timeRange * CHART_PADDING_PERCENTAGE;
+
+    return {
+      tooltip: {
+        trigger: 'axis',
+        formatter: createTooltipFormatter(selectedMetric),
+        axisPointer: {
+          type: 'cross',
+          label: {
+            backgroundColor: '#6a7985',
+          },
+        },
+        // Fix hover popup positioning to keep it within chart area
+        confine: true,
+        position: function (point: [number, number], params: unknown, dom: 
HTMLElement, rect: { x: number; y: number; width: number; height: number }, 
size: { viewSize: [number, number]; contentSize: [number, number] }) {
+          // Ensure tooltip stays within chart bounds
+          const [x, y] = point;
+          const [viewWidth, viewHeight] = size.viewSize;
+          const [contentWidth, contentHeight] = size.contentSize;
+
+          let posX = x + 10;
+          let posY = y - 10;
+
+          // Adjust horizontal position if tooltip would go outside
+          if (posX + contentWidth > viewWidth) {
+            posX = x - contentWidth - 10;
+          }
+
+          // Adjust vertical position if tooltip would go outside
+          if (posY - contentHeight < 0) {
+            posY = y + 10;
+          }
+
+          return [posX, posY];
+        },
+      },
+      // Remove legend - colors will be shown in the stats table instead
+      grid: {
+        left: '3%',
+        right: '4%',
+        bottom: '3%',
+        top: '15%',
+        containLabel: true,
+      },
+      xAxis: {
+        type: 'time',
+        boundaryGap: false,
+        min: minTime - padding, // Apply consistent padding
+        max: maxTime + padding, // Apply consistent padding
+        axisLabel: {
+          formatter: function (value: number) {
+            return new Date(value).toLocaleTimeString();
+          },
+        },
+      },
+      yAxis: {
+        type: 'value',
+        axisLabel: {
+          formatter: function (value: number) {
+            return value.toFixed(2);
+          },
+        },
+      },
+      dataZoom: [
+        {
+          type: 'inside',
+          start: 0,
+          end: 100,
+          zoomOnMouseWheel: true,
+          moveOnMouseMove: true,
+          moveOnMouseWheel: false,
+          preventDefaultMouseMove: false,
+          filterMode: 'filter',
+          rangeMode: ['value', 'value'],
+        },
+      ],
+      toolbox: {
+        feature: {
+          dataZoom: {
+            yAxisIndex: 'none',
+            title: {
+              zoom: 'Zoom Selection'
+            }
+          },
+        },
+        right: 20,
+        top: 20,
+      },
+      series: chartSeries,
+    };
+  }, [series, selectedMetric]);
+
+  if (!series || series.length === 0) {
+    return (
+      <Paper className={classes.chartContainer}>
+        <div className={classes.noDataMessage}>
+          <Typography variant="h6">No timeseries data available</Typography>
+        </div>
+      </Paper>
+    );
+  }
+
+  const hasMoreSeries = series.length > MAX_SERIES_LIMIT;
+
+  return (
+    <Paper className={classes.chartContainer} style={{ height: `${height}px` 
}}>
+      {hasMoreSeries && (
+        <Typography variant="body2" color="textSecondary" style={{ padding: 
'8px 16px', backgroundColor: '#fff3cd', borderBottom: '1px solid #ffeaa7' }}>
+          Showing first {MAX_SERIES_LIMIT} of {series.length} series. Consider 
filtering your query to improve performance.
+        </Typography>
+      )}
+      <ReactECharts
+        option={chartOption}
+        style={{ height: hasMoreSeries ? 'calc(100% - 40px)' : '100%', width: 
'100%' }}
+        opts={{ renderer: 'canvas' }}
+      />
+    </Paper>
+  );
+};
+
+export default TimeseriesChart;
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 c58db897c1..03922a0bc2 100644
--- 
a/pinot-controller/src/main/resources/app/components/Query/TimeseriesQueryPage.tsx
+++ 
b/pinot-controller/src/main/resources/app/components/Query/TimeseriesQueryPage.tsx
@@ -28,7 +28,9 @@ import {
   makeStyles,
   Button,
   Input,
-  FormControlLabel
+  FormControlLabel,
+  ButtonGroup,
+  Box
 } from '@material-ui/core';
 import Alert from '@material-ui/lab/Alert';
 import FileCopyIcon from '@material-ui/icons/FileCopy';
@@ -41,6 +43,41 @@ import { useHistory, useLocation } from 'react-router';
 import TableToolbar from '../TableToolbar';
 import { Resizable } from 're-resizable';
 import SimpleAccordion from '../SimpleAccordion';
+import TimeseriesChart from './TimeseriesChart';
+import MetricStatsTable from './MetricStatsTable';
+import { parseTimeseriesResponse, isPrometheusFormat } from 
'../../utils/TimeseriesUtils';
+import { ChartSeries } from 'Models';
+import { MAX_SERIES_LIMIT } from '../../utils/ChartConstants';
+
+// Define proper types
+interface TimeseriesQueryResponse {
+  data: {
+    resultType: string;
+    result: Array<{
+      metric: Record<string, string>;
+      values: [number, number][];
+    }>;
+  };
+}
+
+interface CodeMirrorEditor {
+  getValue: () => string;
+  setValue: (value: string) => void;
+}
+
+interface CodeMirrorChangeData {
+  from: { line: number; ch: number };
+  to: { line: number; ch: number };
+  text: string[];
+  removed: string[];
+  origin: string;
+}
+
+interface KeyboardEvent {
+  keyCode: number;
+  metaKey: boolean;
+  ctrlKey: boolean;
+}
 
 const useStyles = makeStyles((theme) => ({
   rightPanel: {},
@@ -106,8 +143,75 @@ const useStyles = makeStyles((theme) => ({
     padding: theme.spacing(1),
     minWidth: 0,
   },
+
 }));
 
+// Extract warning component
+const TruncationWarning: React.FC<{ totalSeries: number; truncatedSeries: 
number }> = ({
+  totalSeries,
+  truncatedSeries
+}) => {
+  if (totalSeries <= truncatedSeries) return null;
+
+  return (
+    <Alert severity="warning" style={{ marginBottom: '16px' }}>
+      <Typography variant="body2">
+        Large dataset detected: Showing first {truncatedSeries} of 
{totalSeries} series for visualization.
+        Switch to JSON view to see the complete dataset.
+      </Typography>
+    </Alert>
+  );
+};
+
+// Extract view toggle component
+const ViewToggle: React.FC<{
+  viewType: 'json' | 'chart';
+  onViewChange: (view: 'json' | 'chart') => void;
+  isChartDisabled: boolean;
+  onCopy: () => void;
+  copyMsg: boolean;
+  classes: ReturnType<typeof useStyles>;
+}> = ({ viewType, onViewChange, isChartDisabled, onCopy, copyMsg, classes }) 
=> (
+  <Grid container className={classes.actionBtns} alignItems="center" 
justify="space-between">
+    <Grid item>
+      <ButtonGroup color="primary" size="small">
+        <Button
+          onClick={() => onViewChange('chart')}
+          variant={viewType === 'chart' ? "contained" : "outlined"}
+          disabled={isChartDisabled}
+        >
+          Chart
+        </Button>
+        <Button
+          onClick={() => onViewChange('json')}
+          variant={viewType === 'json' ? "contained" : "outlined"}
+        >
+          JSON
+        </Button>
+      </ButtonGroup>
+    </Grid>
+    <Grid item>
+      <Button
+        variant="contained"
+        color="primary"
+        size="small"
+        className={classes.btn}
+        onClick={onCopy}
+      >
+        Copy
+      </Button>
+      {copyMsg && (
+        <Alert
+          icon={<FileCopyIcon fontSize="inherit" />}
+          severity="info"
+        >
+          Copied results to Clipboard
+        </Alert>
+      )}
+    </Grid>
+  </Grid>
+);
+
 const jsonoptions = {
   lineNumbers: true,
   mode: 'application/json',
@@ -148,11 +252,16 @@ const TimeseriesQueryPage = () => {
   });
 
   const [rawOutput, setRawOutput] = useState<string>('');
-  const [rawData, setRawData] = useState<any>(null);
+  const [rawData, setRawData] = useState<TimeseriesQueryResponse | null>(null);
+  const [chartSeries, setChartSeries] = useState<ChartSeries[]>([]);
+  const [truncatedChartSeries, setTruncatedChartSeries] = 
useState<ChartSeries[]>([]);
   const [isLoading, setIsLoading] = useState<boolean>(false);
   const [error, setError] = useState<string>('');
   const [shouldAutoExecute, setShouldAutoExecute] = useState<boolean>(false);
   const [copyMsg, showCopyMsg] = React.useState(false);
+  const [viewType, setViewType] = useState<'json' | 'chart'>('chart');
+  const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
+
 
   // Update config when URL parameters change
   useEffect(() => {
@@ -198,15 +307,15 @@ const TimeseriesQueryPage = () => {
     });
   }, [history, location.pathname]);
 
-  const handleConfigChange = (field: keyof TimeseriesQueryConfig, value: any) 
=> {
+  const handleConfigChange = (field: keyof TimeseriesQueryConfig, value: 
string | number | boolean) => {
     setConfig(prev => ({ ...prev, [field]: value }));
   };
 
-  const handleQueryChange = (editor: any, data: any, value: string) => {
+  const handleQueryChange = (editor: CodeMirrorEditor, data: 
CodeMirrorChangeData, value: string) => {
     setConfig(prev => ({ ...prev, query: value }));
   };
 
-  const handleQueryInterfaceKeyDown = (editor: any, event: any) => {
+  const handleQueryInterfaceKeyDown = (editor: CodeMirrorEditor, event: 
KeyboardEvent) => {
     const modifiedEnabled = event.metaKey == true || event.ctrlKey == true;
 
     // Map (Cmd/Ctrl) + Enter KeyPress to executing the query
@@ -221,6 +330,25 @@ const TimeseriesQueryPage = () => {
     handleQueryInterfaceKeyDownRef.current = handleQueryInterfaceKeyDown;
   }, [handleQueryInterfaceKeyDown]);
 
+  // Extract data processing logic
+  const processQueryResponse = useCallback((parsedData: 
TimeseriesQueryResponse) => {
+    setRawData(parsedData);
+    setRawOutput(JSON.stringify(parsedData, null, 2));
+
+    // Parse timeseries data for chart and stats
+    if (isPrometheusFormat(parsedData)) {
+      const series = parseTimeseriesResponse(parsedData);
+      setChartSeries(series);
+
+      // Create truncated series for visualization (limit to MAX_SERIES_LIMIT)
+      const truncatedSeries = series.slice(0, MAX_SERIES_LIMIT);
+      setTruncatedChartSeries(truncatedSeries);
+    } else {
+      setChartSeries([]);
+      setTruncatedChartSeries([]);
+    }
+  }, []);
+
   const handleExecuteQuery = useCallback(async () => {
     if (!config.query.trim()) {
       setError('Please enter a query');
@@ -250,8 +378,7 @@ const TimeseriesQueryPage = () => {
         ? JSON.parse(response.data)
         : response.data;
 
-      setRawData(parsedData);
-      setRawOutput(JSON.stringify(parsedData, null, 2));
+      processQueryResponse(parsedData);
     } catch (error) {
       console.error('Error executing timeseries query:', error);
       const errorMessage = error.response?.data?.message || error.message || 
'Unknown error occurred';
@@ -259,7 +386,7 @@ const TimeseriesQueryPage = () => {
     } finally {
       setIsLoading(false);
     }
-  }, [config, updateURL]);
+  }, [config, updateURL, processQueryResponse]);
 
   const copyToClipboard = () => {
     const aux = document.createElement('input');
@@ -312,7 +439,7 @@ const TimeseriesQueryPage = () => {
               <InputLabel>Query Language</InputLabel>
               <Select
                 value={config.queryLanguage}
-                onChange={(e) => handleConfigChange('queryLanguage', 
e.target.value)}
+                onChange={(e) => handleConfigChange('queryLanguage', 
e.target.value as string)}
               >
                 {SUPPORTED_QUERY_LANGUAGES.map((lang) => (
                   <MenuItem key={lang.value} value={lang.value}>
@@ -329,7 +456,7 @@ const TimeseriesQueryPage = () => {
               <Input
                 type="text"
                 value={config.startTime}
-                onChange={(e) => handleConfigChange('startTime', 
e.target.value)}
+                onChange={(e) => handleConfigChange('startTime', 
e.target.value as string)}
                 placeholder={getOneMinuteAgoTimestamp()}
               />
             </FormControl>
@@ -341,7 +468,7 @@ const TimeseriesQueryPage = () => {
               <Input
                 type="text"
                 value={config.endTime}
-                onChange={(e) => handleConfigChange('endTime', e.target.value)}
+                onChange={(e) => handleConfigChange('endTime', e.target.value 
as string)}
                 placeholder={getCurrentTimestamp()}
               />
             </FormControl>
@@ -353,7 +480,7 @@ const TimeseriesQueryPage = () => {
               <Input
                 type="text"
                 value={config.timeout}
-                onChange={(e) => handleConfigChange('timeout', 
parseInt(e.target.value) || 60000)}
+                onChange={(e) => handleConfigChange('timeout', 
parseInt(e.target.value as string) || 60000)}
               />
             </FormControl>
           </Grid>
@@ -379,36 +506,65 @@ const TimeseriesQueryPage = () => {
 
         {rawOutput && (
           <Grid item xs style={{ backgroundColor: 'white' }}>
-            <Grid container className={classes.actionBtns}>
-              <Button
-                variant="contained"
-                color="primary"
-                size="small"
-                className={classes.btn}
-                onClick={copyToClipboard}
+                         <ViewToggle
+               viewType={viewType}
+               onViewChange={setViewType}
+               isChartDisabled={truncatedChartSeries.length === 0}
+               onCopy={copyToClipboard}
+               copyMsg={copyMsg}
+               classes={classes}
+             />
+
+                                    {viewType === 'chart' && (
+              <SimpleAccordion
+                headerTitle="Timeseries Chart & Statistics"
+                showSearchBox={false}
               >
-                Copy
-              </Button>
-              {copyMsg && (
-                <Alert
-                  icon={<FileCopyIcon fontSize="inherit" />}
-                  severity="info"
-                >
-                  Copied results to Clipboard
-                </Alert>
-              )}
-            </Grid>
-            <SimpleAccordion
-              headerTitle="Query Result (JSON Format)"
-              showSearchBox={false}
-            >
-              <CodeMirror
-                options={jsonoptions}
-                value={rawOutput}
-                className={classes.queryOutput}
-                autoCursor={false}
-              />
-            </SimpleAccordion>
+                {truncatedChartSeries.length > 0 ? (
+                  <>
+                    <TruncationWarning
+                      totalSeries={chartSeries.length}
+                      truncatedSeries={truncatedChartSeries.length}
+                    />
+                    <TimeseriesChart
+                      series={truncatedChartSeries}
+                      height={500}
+                      selectedMetric={selectedMetric}
+                    />
+                    <MetricStatsTable
+                      series={truncatedChartSeries}
+                      selectedMetric={selectedMetric}
+                      onMetricSelect={setSelectedMetric}
+                    />
+                  </>
+                ) : (
+                  <Box p={3} textAlign="center">
+                    <Typography variant="h6" color="textSecondary" 
gutterBottom>
+                      No Chart Data Available
+                    </Typography>
+                    <Typography variant="body2" color="textSecondary">
+                      The query response is not in Prometheus-compatible 
format or contains no timeseries data.
+                      <br />
+                      Switch to JSON view to see the raw response.
+                    </Typography>
+                  </Box>
+                )}
+              </SimpleAccordion>
+            )}
+
+            {viewType === 'json' && (
+              <SimpleAccordion
+                headerTitle="Query Result (JSON Format)"
+                showSearchBox={false}
+              >
+                <CodeMirror
+                  options={jsonoptions}
+                  value={rawOutput}
+                  className={classes.queryOutput}
+                  autoCursor={false}
+                />
+              </SimpleAccordion>
+            )}
           </Grid>
         )}
       </Grid>
diff --git a/pinot-controller/src/main/resources/app/interfaces/types.d.ts 
b/pinot-controller/src/main/resources/app/interfaces/types.d.ts
index 3808b38c04..e38d9d578b 100644
--- a/pinot-controller/src/main/resources/app/interfaces/types.d.ts
+++ b/pinot-controller/src/main/resources/app/interfaces/types.d.ts
@@ -152,7 +152,7 @@ declare module 'Models' {
     serversUnparsableRespond: number;
     _segmentToConsumingInfoMap: Record<string, ConsumingInfo[]>;
   }
-  
+
   /**
    * Pause status information for a realtime table
    */
@@ -225,6 +225,52 @@ declare module 'Models' {
     realtimeTotalCpuTimeNs: number
   };
 
+  // Timeseries related types
+  export interface TimeseriesData {
+    metric: Record<string, string>;
+    values: [number, number][]; // [timestamp, value]
+  }
+
+  export interface TimeseriesResponse {
+    status: string;
+    data: {
+      resultType: string;
+      result: TimeseriesData[];
+    };
+  }
+
+  export interface MetricStats {
+    min: number;
+    max: number;
+    avg: number;
+    sum: number;
+    count: number;
+    firstValue: number;
+    lastValue: number;
+  }
+
+  export interface ChartDataPoint {
+    timestamp: number;
+    value: number;
+    formattedTime: string;
+  }
+
+  export interface ChartSeries {
+    name: string;
+    data: ChartDataPoint[];
+    stats: MetricStats;
+  }
+
+  export type TimeseriesViewType = 'json' | 'chart' | 'stats';
+
+  export interface TimeseriesQueryConfig {
+    queryLanguage: string;
+    query: string;
+    startTime: string;
+    endTime: string;
+    timeout: number;
+  }
+
   export type ClusterName = {
     clusterName: string
   };
@@ -313,7 +359,7 @@ declare module 'Models' {
   export type RebalanceTableSegmentJobs = {
     [key: string]: RebalanceTableSegmentJob;
   }
-  
+
   export interface TaskRuntimeConfig {
     ConcurrentTasksPerWorker: string,
     TaskTimeoutMs: string,
@@ -343,7 +389,7 @@ declare module 'Models' {
     OFFLINE = "OFFLINE",
     CONSUMING = "CONSUMING",
     ERROR = "ERROR"
-  } 
+  }
 
   export const enum DISPLAY_SEGMENT_STATUS {
     BAD = "BAD",
diff --git a/pinot-controller/src/main/resources/app/utils/ChartConstants.ts 
b/pinot-controller/src/main/resources/app/utils/ChartConstants.ts
new file mode 100644
index 0000000000..5e1f823c85
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/utils/ChartConstants.ts
@@ -0,0 +1,45 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Standard chart colors matching the ECharts palette
+export const CHART_COLORS = [
+    "#5470C6", "#91CC75", "#EE6666", "#FAC858", "#73C0DE",
+    "#3BA272", "#FC8452", "#9A60B4", "#EA7CCC", "#6E7074",
+    "#546570", "#C4CCD3", "#F05B72", "#FF715E", "#FFAF51",
+    "#FFE153", "#47B39C", "#5BACE1", "#32C5E9", "#96BFFF"
+];
+
+/**
+ * Maximum number of series that can be rendered in the chart
+ */
+export const MAX_SERIES_LIMIT = 20;
+
+/**
+ * Chart padding percentage for time axis and series
+ */
+export const CHART_PADDING_PERCENTAGE = 0.05; // 5%
+
+/**
+ * Get color for a series by index
+ * @param index - The series index
+ * @returns The color for the series
+ */
+export const getSeriesColor = (index: number): string => {
+  return CHART_COLORS[index % CHART_COLORS.length];
+};
diff --git a/pinot-controller/src/main/resources/app/utils/TimeseriesUtils.ts 
b/pinot-controller/src/main/resources/app/utils/TimeseriesUtils.ts
new file mode 100644
index 0000000000..49dd041173
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/utils/TimeseriesUtils.ts
@@ -0,0 +1,161 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ChartSeries, TimeseriesData, ChartDataPoint, MetricStats } from 
'Models';
+
+// Define proper types for API responses
+interface PrometheusResponse {
+  data: {
+    resultType?: string;
+    result: TimeseriesData[];
+  };
+}
+
+/**
+ * Parse Prometheus-compatible timeseries response
+ */
+export const parseTimeseriesResponse = (response: PrometheusResponse): 
ChartSeries[] => {
+  if (!response || !response.data || !response.data.result) {
+    return [];
+  }
+
+  return response.data.result.map((series: TimeseriesData) => {
+    const dataPoints: ChartDataPoint[] = series.values.map(([timestamp, 
value]) => ({
+      timestamp: timestamp * 1000, // Convert to milliseconds
+      value: parseFloat(String(value)),
+      formattedTime: new Date(timestamp * 1000).toLocaleString()
+    }));
+
+    const stats = calculateMetricStats(dataPoints);
+    const seriesName = formatSeriesName(series.metric);
+
+    return {
+      name: seriesName,
+      data: dataPoints,
+      stats
+    };
+  });
+};
+
+/**
+ * Calculate statistics for a metric series
+ */
+export const calculateMetricStats = (dataPoints: ChartDataPoint[]): 
MetricStats => {
+  if (dataPoints.length === 0) {
+    return {
+      min: NaN,
+      max: NaN,
+      avg: NaN,
+      sum: NaN,
+      count: 0,
+      firstValue: NaN,
+      lastValue: NaN
+    };
+  }
+
+  // Filter out null/undefined values and check if we have any valid values
+  const validValues = dataPoints
+    .map(dp => dp.value)
+    .filter(val => val !== null && val !== undefined && !isNaN(val));
+
+  if (validValues.length === 0) {
+    return {
+      min: NaN,
+      max: NaN,
+      avg: NaN,
+      sum: NaN,
+      count: 0,
+      firstValue: NaN,
+      lastValue: NaN
+    };
+  }
+
+  const sum = validValues.reduce((acc, val) => acc + val, 0);
+  const avg = sum / validValues.length;
+  const min = Math.min(...validValues);
+  const max = Math.max(...validValues);
+
+  // Find first and last valid values
+  const firstValidIndex = dataPoints.findIndex(dp => dp.value !== null && 
dp.value !== undefined && !isNaN(dp.value));
+  const lastValidIndex = dataPoints.length - 1 - 
dataPoints.slice().reverse().findIndex(dp => dp.value !== null && dp.value !== 
undefined && !isNaN(dp.value));
+
+  const firstValue = firstValidIndex >= 0 ? dataPoints[firstValidIndex].value 
: NaN;
+  const lastValue = lastValidIndex >= 0 ? dataPoints[lastValidIndex].value : 
NaN;
+
+  return {
+    min,
+    max,
+    avg,
+    sum,
+    count: validValues.length,
+    firstValue,
+    lastValue
+  };
+};
+
+/**
+ * Format series name from metric labels
+ */
+export const formatSeriesName = (metric: Record<string, string>): string => {
+  if (!metric || Object.keys(metric).length === 0) {
+    return 'Unknown Metric';
+  }
+
+  // Try to find a meaningful name from common label patterns
+  const name = metric.__name__ || metric.name || metric.metric || metric.job 
|| metric.instance;
+
+  if (name) {
+    // Add additional context if available, but exclude the main metric name
+    const additionalLabels = Object.entries(metric)
+      .filter(([key]) => !['__name__', 'name', 'metric', 'job', 
'instance'].includes(key))
+      .map(([key, value]) => `${key}="${value}"`)
+      .join(', ');
+
+    // Return just the name without the {} wrapper
+    return name;
+  }
+
+  // Fallback to just the first label without {}
+  const firstLabel = Object.entries(metric)[0];
+  return firstLabel ? firstLabel[0] : 'Unknown Metric';
+};
+
+/**
+ * Check if response is in Prometheus format
+ */
+export const isPrometheusFormat = (response: PrometheusResponse): boolean => {
+  return response &&
+         response.data &&
+         Array.isArray(response.data.result);
+};
+
+/**
+ * Get time range from data points
+ */
+export const getTimeRange = (dataPoints: ChartDataPoint[]): { start: number; 
end: number } => {
+  if (dataPoints.length === 0) {
+    return { start: 0, end: 0 };
+  }
+
+  const timestamps = dataPoints.map(dp => dp.timestamp);
+  return {
+    start: Math.min(...timestamps),
+    end: Math.max(...timestamps)
+  };
+};
diff --git a/pinot-controller/src/main/resources/package-lock.json 
b/pinot-controller/src/main/resources/package-lock.json
index 56e1d4ba31..b38702a64c 100644
--- a/pinot-controller/src/main/resources/package-lock.json
+++ b/pinot-controller/src/main/resources/package-lock.json
@@ -25,6 +25,8 @@
         "codemirror": "5.65.5",
         "cross-fetch": "3.1.5",
         "dagre": "^0.8.5",
+        "echarts": "^5.4.3",
+        "echarts-for-react": "^3.0.2",
         "export-from-json": "1.6.0",
         "file": "0.2.2",
         "json-bigint": "1.0.0",
@@ -3599,6 +3601,36 @@
         "stream-shift": "^1.0.0"
       }
     },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz";,
+      "integrity": 
"sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "node_modules/echarts-for-react": {
+      "version": "3.0.2",
+      "resolved": 
"https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz";,
+      "integrity": 
"sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "size-sensor": "^1.0.1"
+      },
+      "peerDependencies": {
+        "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0",
+        "react": "^15.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/echarts/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz";,
+      "integrity": 
"sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/ee-first": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz";,
@@ -4817,8 +4849,7 @@
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": 
"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz";,
-      "integrity": 
"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
+      "integrity": 
"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "node_modules/fast-diff": {
       "version": "1.3.0",
@@ -10694,6 +10725,12 @@
       "integrity": 
"sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
       "dev": true
     },
+    "node_modules/size-sensor": {
+      "version": "1.0.2",
+      "resolved": 
"https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz";,
+      "integrity": 
"sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==",
+      "license": "ISC"
+    },
     "node_modules/slash": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz";,
@@ -12956,6 +12993,21 @@
         "node": ">=6"
       }
     },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz";,
+      "integrity": 
"sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
+    "node_modules/zrender/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz";,
+      "integrity": 
"sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/zustand": {
       "version": "3.7.2",
       "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz";,
@@ -15742,6 +15794,31 @@
         "stream-shift": "^1.0.0"
       }
     },
+    "echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz";,
+      "integrity": 
"sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "requires": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz";,
+          "integrity": 
"sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+        }
+      }
+    },
+    "echarts-for-react": {
+      "version": "3.0.2",
+      "resolved": 
"https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz";,
+      "integrity": 
"sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==",
+      "requires": {
+        "fast-deep-equal": "^3.1.3",
+        "size-sensor": "^1.0.1"
+      }
+    },
     "ee-first": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz";,
@@ -16671,8 +16748,7 @@
     "fast-deep-equal": {
       "version": "3.1.3",
       "resolved": 
"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz";,
-      "integrity": 
"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
+      "integrity": 
"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "fast-diff": {
       "version": "1.3.0",
@@ -21106,6 +21182,11 @@
       "integrity": 
"sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
       "dev": true
     },
+    "size-sensor": {
+      "version": "1.0.2",
+      "resolved": 
"https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz";,
+      "integrity": 
"sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw=="
+    },
     "slash": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz";,
@@ -22784,6 +22865,21 @@
         "decamelize": "^1.2.0"
       }
     },
+    "zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz";,
+      "integrity": 
"sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "requires": {
+        "tslib": "2.3.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz";,
+          "integrity": 
"sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+        }
+      }
+    },
     "zustand": {
       "version": "3.7.2",
       "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz";,
diff --git a/pinot-controller/src/main/resources/package.json 
b/pinot-controller/src/main/resources/package.json
index d7ac3ff60e..f1189f9a74 100644
--- a/pinot-controller/src/main/resources/package.json
+++ b/pinot-controller/src/main/resources/package.json
@@ -80,6 +80,8 @@
     "codemirror": "5.65.5",
     "cross-fetch": "3.1.5",
     "dagre": "^0.8.5",
+    "echarts": "^5.4.3",
+    "echarts-for-react": "^3.0.2",
     "export-from-json": "1.6.0",
     "file": "0.2.2",
     "json-bigint": "1.0.0",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to