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]