feat: implement explore section in metrics explorer (#7256)

This commit is contained in:
Amlan Kumar Nandy 2025-03-11 12:18:09 +05:30 committed by GitHub
parent 1b758a088c
commit 527d8a4fc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1071 additions and 29 deletions

View File

@ -0,0 +1,60 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export interface RelatedMetricsPayload {
start: number;
end: number;
currentMetricName: string;
}
export interface RelatedMetricDashboard {
dashboard_name: string;
dashboard_id: string;
widget_id: string;
widget_name: string;
}
export interface RelatedMetricAlert {
alert_name: string;
alert_id: string;
}
export interface RelatedMetric {
name: string;
query: IBuilderQuery;
dashboards: RelatedMetricDashboard[];
alerts: RelatedMetricAlert[];
}
export interface RelatedMetricsResponse {
status: 'success';
data: {
related_metrics: RelatedMetric[];
};
}
export const getRelatedMetrics = async (
props: RelatedMetricsPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<RelatedMetricsResponse> | ErrorResponse> => {
try {
const response = await axios.post('/metrics/related', props, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
params: props,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@ -50,4 +50,5 @@ export const REACT_QUERY_KEY = {
GET_METRICS_LIST_FILTER_KEYS: 'GET_METRICS_LIST_FILTER_KEYS',
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
};

View File

@ -100,11 +100,19 @@ function ExplorerOptions({
const ref = useRef<RefSelectProps>(null);
const isDarkMode = useIsDarkMode();
const isLogsExplorer = sourcepage === DataSource.LOGS;
const isMetricsExplorer = sourcepage === DataSource.METRICS;
const PRESERVED_VIEW_LOCAL_STORAGE_KEY = LOCALSTORAGE.LAST_USED_SAVED_VIEWS;
const PRESERVED_VIEW_TYPE = isLogsExplorer
? PreservedViewsTypes.LOGS
: PreservedViewsTypes.TRACES;
const PRESERVED_VIEW_TYPE = useMemo(() => {
if (isLogsExplorer) {
return PreservedViewsTypes.LOGS;
}
if (isMetricsExplorer) {
return PreservedViewsTypes.METRICS;
}
return PreservedViewsTypes.TRACES;
}, [isLogsExplorer, isMetricsExplorer]);
const onModalToggle = useCallback((value: boolean) => {
setIsExport(value);
@ -127,6 +135,10 @@ function ExplorerOptions({
logEvent('Logs Explorer: Save view clicked', {
panelType,
});
} else if (isMetricsExplorer) {
logEvent('Metrics Explorer: Save view clicked', {
panelType,
});
}
setIsSaveModalOpen(!isSaveModalOpen);
};
@ -161,6 +173,10 @@ function ExplorerOptions({
logEvent('Logs Explorer: Create alert', {
panelType,
});
} else if (isMetricsExplorer) {
logEvent('Metrics Explorer: Create alert', {
panelType,
});
}
const stringifiedQuery = handleConditionalQueryModification();
@ -186,6 +202,10 @@ function ExplorerOptions({
logEvent('Logs Explorer: Add to dashboard clicked', {
panelType,
});
} else if (isMetricsExplorer) {
logEvent('Metrics Explorer: Add to dashboard clicked', {
panelType,
});
}
setIsExport(true);
};
@ -395,6 +415,11 @@ function ExplorerOptions({
panelType,
viewName: option?.value,
});
} else if (isMetricsExplorer) {
logEvent('Metrics Explorer: Select view', {
panelType,
viewName: option?.value,
});
}
updatePreservedViewInLocalStorage(option);
@ -491,6 +516,11 @@ function ExplorerOptions({
panelType,
viewName: newViewName,
});
} else if (isMetricsExplorer) {
logEvent('Metrics Explorer: Save view successful', {
panelType,
viewName: newViewName,
});
}
};
@ -570,6 +600,27 @@ function ExplorerOptions({
viewsData?.data?.data,
]);
const infoIconText = useMemo(() => {
if (isLogsExplorer) {
return 'Learn more about Logs explorer';
}
if (isMetricsExplorer) {
return 'Learn more about Metrics explorer';
}
return 'Learn more about Traces explorer';
}, [isLogsExplorer, isMetricsExplorer]);
const infoIconLink = useMemo(() => {
if (isLogsExplorer) {
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
}
// TODO: Add metrics explorer info icon link
if (isMetricsExplorer) {
return '';
}
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
}, [isLogsExplorer, isMetricsExplorer]);
return (
<div className="explorer-options-container">
{
@ -693,28 +744,22 @@ function ExplorerOptions({
</Button>
</div>
<div className="actions">
<Tooltip
title={
<div>
{isLogsExplorer
? 'Learn more about Logs explorer '
: 'Learn more about Traces explorer '}
<Typography.Link
href={
isLogsExplorer
? 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar'
: 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar'
}
target="_blank"
>
{' '}
here
</Typography.Link>{' '}
</div>
}
>
<InfoCircleOutlined className="info-icon" />
</Tooltip>
{/* Hide the info icon for metrics explorer until we get the docs link */}
{!isMetricsExplorer && (
<Tooltip
title={
<div>
{infoIconText}
<Typography.Link href={infoIconLink} target="_blank">
{' '}
here
</Typography.Link>{' '}
</div>
}
>
<InfoCircleOutlined className="info-icon" />
</Tooltip>
)}
<Tooltip title="Hide">
<Button
disabled={disabled}

View File

@ -1,4 +1,5 @@
export enum PreservedViewsTypes {
LOGS = 'logs',
TRACES = 'traces',
METRICS = 'metrics',
}

View File

@ -31,7 +31,8 @@ export interface SaveNewViewHandlerProps {
export type PreservedViewType =
| PreservedViewsTypes.LOGS
| PreservedViewsTypes.TRACES;
| PreservedViewsTypes.TRACES
| PreservedViewsTypes.METRICS;
export type PreservedViewsInLocalStorage = Partial<
Record<PreservedViewType, { key: string; value: string }>

View File

@ -15,7 +15,7 @@ export const getRandomColor = (): string => {
};
export const DATASOURCE_VS_ROUTES: Record<DataSource, string> = {
[DataSource.METRICS]: '',
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER,
[DataSource.TRACES]: ROUTES.TRACES_EXPLORER,
[DataSource.LOGS]: ROUTES.LOGS_EXPLORER,
};

View File

@ -0,0 +1,172 @@
.metrics-explorer-explore-container {
padding-bottom: 16px;
.explore-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 0;
.explore-header-left-actions {
display: flex;
align-items: center;
gap: 10px;
}
.explore-header-right-actions {
display: flex;
align-items: center;
gap: 10px;
}
}
.query-section {
max-height: 450px;
overflow-y: scroll;
}
.explore-tabs {
margin: 15px 0;
.tab {
background-color: var(--bg-slate-500);
border-color: var(--bg-ink-200);
width: 180px;
padding: 16px 0;
display: flex;
justify-content: center;
align-items: center;
}
.tab:first-of-type {
border-top-left-radius: 2px;
}
.tab:last-of-type {
border-top-right-radius: 2px;
}
.selected-view {
background: var(--bg-ink-500);
}
}
.explore-content {
.time-series-container {
display: flex;
gap: 10px;
width: 100%;
height: fit-content;
overflow-y: scroll;
.time-series-view {
min-width: 100%;
width: 100%;
}
}
.related-metrics-container {
width: 100%;
min-height: 300px;
max-height: 450px;
display: flex;
flex-direction: column;
gap: 10px;
.related-metrics-header {
display: flex;
align-items: center;
justify-content: flex-start;
.metric-name-select {
width: 20%;
margin-right: 10px;
}
.related-metrics-input {
width: 40%;
.ant-input-wrapper {
.ant-input-group-addon {
.related-metrics-select {
width: 250px;
border: 1px solid var(--bg-slate-500) !important;
.ant-select-selector {
text-align: left;
color: var(--text-vanilla-500) !important;
}
}
}
}
}
}
.related-metrics-body {
padding: 10px 0;
.related-metrics-card-container {
min-height: 300px;
margin-bottom: 25px;
.related-metrics-card {
// height: 400px;
display: flex;
flex-direction: column;
gap: 16px;
.related-metrics-card-error {
padding-top: 10px;
}
}
}
}
}
}
}
.lightMode {
.metrics-explorer-explore-container {
.explore-tabs {
.tab {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-400);
}
.selected-view {
background: var(--bg-vanilla-500);
}
}
}
}
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;
.dashboards-and-alerts-popover {
border-radius: 20px;
padding: 4px 8px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
&:hover {
opacity: 0.8;
}
}
.dashboards-popover {
border: 1px solid var(--bg-sienna-500);
.ant-typography {
color: var(--bg-sienna-500);
}
}
.alerts-popover {
border: 1px solid var(--bg-sakura-500);
.ant-typography {
color: var(--bg-sakura-500);
}
}
}

View File

@ -1,10 +1,190 @@
import './Explorer.styles.scss';
import * as Sentry from '@sentry/react';
import { Button, Switch, Typography } from 'antd';
import axios from 'axios';
import classNames from 'classnames';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { addEmptyWidgetInDashboardJSONWithQuery } from 'hooks/dashboard/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useMemo, useState } from 'react';
import { Dashboard } from 'types/api/dashboard/getAll';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { v4 as uuid } from 'uuid';
import QuerySection from './QuerySection';
import RelatedMetrics from './RelatedMetrics';
import TimeSeries from './TimeSeries';
import { ExplorerTabs } from './types';
function Explorer(): JSX.Element {
const {
handleRunQuery,
stagedQuery,
updateAllQueriesOperators,
currentQuery,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const { notifications } = useNotifications();
const { mutate: updateDashboard, isLoading } = useUpdateDashboard();
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
});
const [showOneChartPerQuery, toggleShowOneChartPerQuery] = useState(false);
const [selectedTab, setSelectedTab] = useState<ExplorerTabs>(
ExplorerTabs.TIME_SERIES,
);
const handleToggleShowOneChartPerQuery = (): void =>
toggleShowOneChartPerQuery(!showOneChartPerQuery);
const metricNames = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length === 0) {
return [];
}
return stagedQuery.builder.queryData.map(
(query) => query.aggregateAttribute.key,
);
}, [stagedQuery]);
const exportDefaultQuery = useMemo(
() =>
updateAllQueriesOperators(
currentQuery || initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
),
[currentQuery, updateAllQueriesOperators],
);
const handleExport = useCallback(
(dashboard: Dashboard | null): void => {
if (!dashboard) return;
const widgetId = uuid();
const updatedDashboard = addEmptyWidgetInDashboardJSONWithQuery(
dashboard,
exportDefaultQuery,
widgetId,
PANEL_TYPES.TIME_SERIES,
options.selectColumns,
);
updateDashboard(updatedDashboard, {
onSuccess: (data) => {
if (data.error) {
const message =
data.error === 'feature usage exceeded' ? (
<span>
Panel limit exceeded for {DataSource.METRICS} in community edition.
Please checkout our paid plans{' '}
<a
href="https://signoz.io/pricing/?utm_source=product&utm_medium=dashboard-limit"
rel="noreferrer noopener"
target="_blank"
>
here
</a>
</span>
) : (
data.error
);
notifications.error({
message,
});
return;
}
const dashboardEditView = generateExportToDashboardLink({
query: exportDefaultQuery,
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: data.payload?.uuid || '',
widgetId,
});
safeNavigate(dashboardEditView);
},
onError: (error) => {
if (axios.isAxiosError(error)) {
notifications.error({
message: error.message,
});
}
},
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[exportDefaultQuery, notifications, updateDashboard],
);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
Explorer
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Switch
checked={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
size="small"
/>
</div>
<div className="explore-header-right-actions">
<DateTimeSelector showAutoRefresh />
<RightToolbarActions onStageRunQuery={handleRunQuery} />
</div>
</div>
<QuerySection />
<Button.Group className="explore-tabs">
<Button
value={ExplorerTabs.TIME_SERIES}
className={classNames('tab', {
'selected-view': selectedTab === ExplorerTabs.TIME_SERIES,
})}
onClick={(): void => setSelectedTab(ExplorerTabs.TIME_SERIES)}
>
<Typography.Text>Time series</Typography.Text>
</Button>
<Button
value={ExplorerTabs.RELATED_METRICS}
className={classNames('tab', {
'selected-view': selectedTab === ExplorerTabs.RELATED_METRICS,
})}
onClick={(): void => setSelectedTab(ExplorerTabs.RELATED_METRICS)}
>
<Typography.Text>Related</Typography.Text>
</Button>
</Button.Group>
<div className="explore-content">
{selectedTab === ExplorerTabs.TIME_SERIES && (
<TimeSeries showOneChartPerQuery={showOneChartPerQuery} />
)}
{selectedTab === ExplorerTabs.RELATED_METRICS && (
<RelatedMetrics metricNames={metricNames} />
)}
</div>
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
isLoading={isLoading}
sourcepage={DataSource.METRICS}
onExport={handleExport}
/>
</Sentry.ErrorBoundary>
);
}

View File

@ -0,0 +1,32 @@
import { Button } from 'antd';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryBuilder } from 'container/QueryBuilder';
import { ButtonWrapper } from 'container/TracesExplorer/QuerySection/styles';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const { handleRunQuery } = useQueryBuilder();
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.TIME_SERIES);
return (
<div className="query-section">
<QueryBuilder
panelType={panelTypes}
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
version="v4"
actions={
<ButtonWrapper>
<Button onClick={(): void => handleRunQuery()} type="primary">
Run Query
</Button>
</ButtonWrapper>
}
/>
</div>
);
}
export default QuerySection;

View File

@ -0,0 +1,167 @@
import { Color } from '@signozhq/design-tokens';
import { Card, Col, Input, Row, Select, Skeleton } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { Gauge } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import RelatedMetricsCard from './RelatedMetricsCard';
import { RelatedMetricsProps, RelatedMetricWithQueryResult } from './types';
import { useGetRelatedMetricsGraphs } from './useGetRelatedMetricsGraphs';
function RelatedMetrics({ metricNames }: RelatedMetricsProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
null,
);
const [selectedRelatedMetric, setSelectedRelatedMetric] = useState('all');
const [searchValue, setSearchValue] = useState<string | null>(null);
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [
maxTime,
]);
useEffect(() => {
if (metricNames.length) {
setSelectedMetricName(metricNames[0]);
}
}, [metricNames]);
const { relatedMetrics, isRelatedMetricsLoading } = useGetRelatedMetricsGraphs(
{
selectedMetricName,
startMs,
endMs,
},
);
const metricNamesSelectOptions = useMemo(
() =>
metricNames.map((name) => ({
value: name,
label: name,
})),
[metricNames],
);
const relatedMetricsSelectOptions = useMemo(() => {
const options: { value: string; label: string }[] = [
{
value: 'all',
label: 'All',
},
];
relatedMetrics.forEach((metric) => {
options.push({
value: metric.name,
label: metric.name,
});
});
return options;
}, [relatedMetrics]);
const filteredRelatedMetrics = useMemo(() => {
let filteredMetrics: RelatedMetricWithQueryResult[] = [];
if (selectedRelatedMetric === 'all') {
filteredMetrics = [...relatedMetrics];
} else {
filteredMetrics = relatedMetrics.filter(
(metric) => metric.name === selectedRelatedMetric,
);
}
if (searchValue?.length) {
filteredMetrics = filteredMetrics.filter((metric) =>
metric.name.toLowerCase().includes(searchValue?.toLowerCase() ?? ''),
);
}
return filteredMetrics;
}, [relatedMetrics, selectedRelatedMetric, searchValue]);
const chartData = useMemo(
() =>
filteredRelatedMetrics.map(({ queryResult }) =>
getUPlotChartData(queryResult.data?.payload),
),
[filteredRelatedMetrics],
);
const options = useMemo(
() =>
filteredRelatedMetrics.map(({ queryResult }) =>
getUPlotChartOptions({
apiResponse: queryResult.data?.payload,
isDarkMode,
dimensions,
yAxisUnit: '',
softMax: null,
softMin: null,
minTimeScale: startMs,
maxTimeScale: endMs,
}),
),
[filteredRelatedMetrics, isDarkMode, dimensions, startMs, endMs],
);
return (
<div className="related-metrics-container">
<div className="related-metrics-header">
<Select
className="metric-name-select"
value={selectedMetricName}
options={metricNamesSelectOptions}
onChange={(value): void => setSelectedMetricName(value)}
suffixIcon={<Gauge size={12} color={Color.BG_SAKURA_500} />}
/>
<Input
className="related-metrics-input"
placeholder="Search..."
onChange={(e): void => setSearchValue(e.target.value)}
bordered
addonBefore={
<Select
loading={isRelatedMetricsLoading}
value={selectedRelatedMetric}
className="related-metrics-select"
options={relatedMetricsSelectOptions}
onChange={(value): void => setSelectedRelatedMetric(value)}
bordered={false}
/>
}
/>
</div>
<div className="related-metrics-body">
{isRelatedMetricsLoading && <Skeleton active />}
<Row gutter={24}>
{filteredRelatedMetrics.map((relatedMetricWithQueryResult, index) => (
<Col span={8} key={relatedMetricWithQueryResult.name}>
<Card bordered ref={graphRef} className="related-metrics-card-container">
<RelatedMetricsCard
key={relatedMetricWithQueryResult.name}
metric={relatedMetricWithQueryResult}
options={options[index]}
chartData={chartData[index]}
/>
</Card>
</Col>
))}
</Row>
</div>
</div>
);
}
export default RelatedMetrics;

View File

@ -0,0 +1,46 @@
import { Skeleton, Typography } from 'antd';
import Uplot from 'components/Uplot';
import DashboardsAndAlertsPopover from '../MetricDetails/DashboardsAndAlertsPopover';
import { RelatedMetricsCardProps } from './types';
function RelatedMetricsCard({
metric,
options,
chartData,
}: RelatedMetricsCardProps): JSX.Element {
const { queryResult } = metric;
if (queryResult.isLoading) {
return <Skeleton />;
}
if (queryResult.error) {
const errorMessage =
(queryResult.error as Error)?.message || 'Something went wrong';
return <div>{errorMessage}</div>;
}
return (
<div className="related-metrics-card">
<Typography.Text className="related-metrics-card-name">
{metric.name}
</Typography.Text>
{queryResult.isLoading ? <Skeleton /> : null}
{queryResult.error ? (
<div className="related-metrics-card-error">
<Typography.Text>Something went wrong</Typography.Text>
</div>
) : null}
{!queryResult.isLoading && !queryResult.error && (
<Uplot options={options} data={chartData} />
)}
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
</div>
);
}
export default RelatedMetricsCard;

View File

@ -0,0 +1,127 @@
import classNames from 'classnames';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { TimeSeriesProps } from './types';
import { splitQueryIntoOneChartPerQuery } from './utils';
function TimeSeries({ showOneChartPerQuery }: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder();
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute.key === 'durationNano' ||
aggregateAttribute.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const queryPayloads = useMemo(
() =>
showOneChartPerQuery
? splitQueryIntoOneChartPerQuery(
stagedQuery || initialQueriesMap[DataSource.METRICS],
)
: [stagedQuery || initialQueriesMap[DataSource.METRICS]],
[showOneChartPerQuery, stagedQuery],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE,
payload,
ENTITY_VERSION_V4,
globalSelectedTime,
maxTime,
minTime,
index,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(
{
query: payload,
graphType: PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: DataSource.METRICS,
},
},
ENTITY_VERSION_V4,
),
enabled: !!payload,
})),
);
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
const responseData = useMemo(
() =>
data.map((datapoint) =>
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
),
[data, isValidToConvertToMs],
);
const changeLayoutForOneChartPerQuery = useMemo(
() => showOneChartPerQuery && queries.length > 1,
[showOneChartPerQuery, queries],
);
return (
<div
className={classNames({
'time-series-container': changeLayoutForOneChartPerQuery,
})}
>
{responseData.map((datapoint, index) => (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
yAxisUnit={isValidToConvertToMs ? 'ms' : 'short'}
dataSource={DataSource.METRICS}
/>
</div>
))}
</div>
);
}
export default TimeSeries;

View File

@ -0,0 +1,39 @@
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export enum ExplorerTabs {
TIME_SERIES = 'time-series',
RELATED_METRICS = 'related-metrics',
}
export interface TimeSeriesProps {
showOneChartPerQuery: boolean;
}
export interface RelatedMetricsProps {
metricNames: string[];
}
export interface RelatedMetricsCardProps {
metric: RelatedMetricWithQueryResult;
options: uPlot.Options;
chartData: any[];
}
export interface UseGetRelatedMetricsGraphsProps {
selectedMetricName: string | null;
startMs: number;
endMs: number;
}
export interface UseGetRelatedMetricsGraphsReturn {
relatedMetrics: RelatedMetricWithQueryResult[];
isRelatedMetricsLoading: boolean;
isRelatedMetricsError: boolean;
}
export interface RelatedMetricWithQueryResult extends RelatedMetric {
queryResult: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>;
}

View File

@ -0,0 +1,111 @@
import { ENTITY_VERSION_V4 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetRelatedMetrics } from 'hooks/metricsExplorer/useGetRelatedMetrics';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuidv4 } from 'uuid';
import { convertNanoToMilliseconds } from '../Summary/utils';
import {
UseGetRelatedMetricsGraphsProps,
UseGetRelatedMetricsGraphsReturn,
} from './types';
export const useGetRelatedMetricsGraphs = ({
selectedMetricName,
startMs,
endMs,
}: UseGetRelatedMetricsGraphsProps): UseGetRelatedMetricsGraphsReturn => {
const { maxTime, minTime, selectedTime: globalSelectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
// Build the query for the related metrics
const relatedMetricsQuery = useMemo(
() => ({
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
currentMetricName: selectedMetricName ?? '',
}),
[selectedMetricName, minTime, maxTime],
);
// Get the related metrics
const {
data: relatedMetricsData,
isLoading: isRelatedMetricsLoading,
isError: isRelatedMetricsError,
} = useGetRelatedMetrics(relatedMetricsQuery, {
enabled: !!selectedMetricName,
});
// Build the related metrics array
const relatedMetrics = useMemo(() => {
if (relatedMetricsData?.payload?.data?.related_metrics) {
return relatedMetricsData.payload.data.related_metrics;
}
return [];
}, [relatedMetricsData]);
// Build the query results for the related metrics
const relatedMetricsQueryResults = useQueries(
useMemo(
() =>
relatedMetrics.map((metric) => ({
queryKey: ['related-metrics', metric.name],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(
{
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: [metric.query],
queryFormulas: [],
},
clickhouse_sql: [],
id: uuidv4(),
},
graphType: PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
start: startMs,
end: endMs,
formatForWeb: false,
params: {
dataSource: DataSource.METRICS,
},
},
ENTITY_VERSION_V4,
),
enabled: !!metric.query,
})),
[relatedMetrics, globalSelectedTime, startMs, endMs],
),
);
// Build the related metrics with query results
const relatedMetricsWithQueryResults = useMemo(
() =>
relatedMetrics.map((metric, index) => ({
...metric,
queryResult: relatedMetricsQueryResults[index],
})),
[relatedMetrics, relatedMetricsQueryResults],
);
return {
relatedMetrics: relatedMetricsWithQueryResults,
isRelatedMetricsLoading,
isRelatedMetricsError,
};
};

View File

@ -0,0 +1,12 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
export const splitQueryIntoOneChartPerQuery = (query: Query): Query[] =>
query.builder.queryData.map((currentQuery) => ({
...query,
id: uuid(),
builder: {
...query.builder,
queryData: [currentQuery],
},
}));

View File

@ -55,7 +55,7 @@ function MetricsTable({
dataSource={data}
columns={metricsTableColumns}
locale={{
emptyText: (
emptyText: isLoading ? null : (
<div className="no-metrics-message-container">
<img
src="/Icons/emptyState.svg"

View File

@ -0,0 +1,48 @@
import {
getRelatedMetrics,
RelatedMetricsPayload,
RelatedMetricsResponse,
} from 'api/metricsExplorer/getRelatedMetrics';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetRelatedMetrics = (
requestData: RelatedMetricsPayload,
options?: UseQueryOptions<
SuccessResponse<RelatedMetricsResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<RelatedMetricsResponse> | ErrorResponse,
Error
>;
export const useGetRelatedMetrics: UseGetRelatedMetrics = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_RELATED_METRICS, requestData];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<RelatedMetricsResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) => getRelatedMetrics(requestData, signal, headers),
...options,
queryKey,
});
};