feat: implement inspect feature for metrics explorer (#7549)

This commit is contained in:
Amlan Kumar Nandy 2025-05-07 12:18:56 +07:00 committed by GitHub
parent 36886135d1
commit f01d21cbf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 4072 additions and 18 deletions

View File

@ -0,0 +1,54 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface InspectMetricsRequest {
metricName: string;
start: number;
end: number;
filters: TagFilter;
}
export interface InspectMetricsResponse {
status: string;
data: {
series: InspectMetricsSeries[];
};
}
export interface InspectMetricsSeries {
title?: string;
strokeColor?: string;
labels: Record<string, string>;
labelsArray: Array<Record<string, string>>;
values: InspectMetricsTimestampValue[];
}
interface InspectMetricsTimestampValue {
timestamp: number;
value: string;
}
export const getInspectMetricsDetails = async (
request: InspectMetricsRequest,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<InspectMetricsResponse> | ErrorResponse> => {
try {
const response = await axios.post(`/metrics/inspect`, request, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@ -1,6 +1,9 @@
import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
export const SpanStyle = styled.span` type SpanProps = React.HTMLAttributes<HTMLSpanElement>;
export const SpanStyle = styled.span<SpanProps>`
position: absolute; position: absolute;
right: -0.313rem; right: -0.313rem;
bottom: 0; bottom: 0;
@ -12,7 +15,7 @@ export const SpanStyle = styled.span`
margin-right: 4px; margin-right: 4px;
`; `;
export const DragSpanStyle = styled.span` export const DragSpanStyle = styled.span<SpanProps>`
display: flex; display: flex;
margin: -1rem; margin: -1rem;
padding: 1rem; padding: 1rem;

View File

@ -51,6 +51,7 @@ export const REACT_QUERY_KEY = {
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES', GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS', GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS', GET_RELATED_METRICS: 'GET_RELATED_METRICS',
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
// API Monitoring Query Keys // API Monitoring Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST', GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',

View File

@ -0,0 +1,350 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Color } from '@signozhq/design-tokens';
import { Card, Tooltip, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import classNames from 'classnames';
import ResizeTable from 'components/ResizeTable/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { ArrowDownCircle, ArrowRightCircle, Focus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
TIME_AGGREGATION_OPTIONS,
} from './constants';
import {
ExpandedViewProps,
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from './types';
import {
formatTimestampToFullDateTime,
getRawDataFromTimeSeries,
getSpaceAggregatedDataFromTimeSeries,
} from './utils';
function ExpandedView({
options,
spaceAggregationSeriesMap,
step,
metricInspectionOptions,
timeAggregatedSeriesMap,
}: ExpandedViewProps): JSX.Element {
const [
selectedTimeSeries,
setSelectedTimeSeries,
] = useState<InspectMetricsSeries | null>(null);
useEffect(() => {
if (step !== InspectionStep.COMPLETED) {
setSelectedTimeSeries(options?.timeSeries ?? null);
} else {
setSelectedTimeSeries(null);
}
}, [step, options?.timeSeries]);
const spaceAggregatedData = useMemo(() => {
if (
!options?.timeSeries ||
!options?.timestamp ||
step !== InspectionStep.COMPLETED
) {
return [];
}
return getSpaceAggregatedDataFromTimeSeries(
options?.timeSeries,
spaceAggregationSeriesMap,
options?.timestamp,
true,
);
}, [options?.timeSeries, options?.timestamp, spaceAggregationSeriesMap, step]);
const rawData = useMemo(() => {
if (!selectedTimeSeries || !options?.timestamp) {
return [];
}
return getRawDataFromTimeSeries(selectedTimeSeries, options?.timestamp, true);
}, [selectedTimeSeries, options?.timestamp]);
const absoluteValue = useMemo(
() =>
options?.timeSeries?.values.find(
(value) => value.timestamp >= options?.timestamp,
)?.value ?? options?.value,
[options],
);
const timeAggregatedData = useMemo(() => {
if (step !== InspectionStep.SPACE_AGGREGATION || !options?.timestamp) {
return [];
}
return (
timeAggregatedSeriesMap
.get(options?.timestamp)
?.filter(
(popoverData) =>
popoverData.title && popoverData.title === options.timeSeries?.title,
) ?? []
);
}, [
step,
options?.timestamp,
options?.timeSeries?.title,
timeAggregatedSeriesMap,
]);
const tableData = useMemo(() => {
if (!selectedTimeSeries) {
return [];
}
return Object.entries(selectedTimeSeries.labels).map(([key, value]) => ({
label: key,
value,
}));
}, [selectedTimeSeries]);
const columns: ColumnsType<DataType> = useMemo(
() => [
{
title: 'Label',
dataIndex: 'label',
key: 'label',
width: 50,
align: 'left',
className: 'labels-key',
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'labels-value',
},
],
[],
);
return (
<div className="expanded-view">
<div className="expanded-view-header">
<Typography.Title level={5}>
<Focus size={16} color={Color.BG_VANILLA_100} />
<div>POINT INSPECTOR</div>
</Typography.Title>
</div>
{/* Show only when space aggregation is completed */}
{step === InspectionStep.COMPLETED && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
<Typography.Text strong>
{`${absoluteValue} is the ${
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW[
metricInspectionOptions.spaceAggregationOption ??
SpaceAggregationOptions.SUM_BY
]
} of`}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{spaceAggregatedData?.map(({ value, title, timestamp }) => (
<Tooltip key={`${title}-${timestamp}-${value}`} title={value}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{value}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIME SERIES
</Typography.Text>
<div className="graph-popover-inner-row">
{spaceAggregatedData?.map(({ title, timeSeries }) => (
<Tooltip key={title} title={title}>
<div
data-testid="graph-popover-cell"
className={classNames('graph-popover-cell', 'timeseries-cell', {
selected: title === selectedTimeSeries?.title,
})}
onClick={(): void => {
setSelectedTimeSeries(timeSeries ?? null);
}}
>
{title}
{selectedTimeSeries?.title === title ? (
<ArrowDownCircle color={Color.BG_FOREST_300} size={12} />
) : (
<ArrowRightCircle size={12} />
)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Show only for space aggregated or raw data */}
{selectedTimeSeries && step !== InspectionStep.SPACE_AGGREGATION && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
{step !== InspectionStep.COMPLETED && (
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
)}
<Typography.Text strong>
{step === InspectionStep.COMPLETED
? `${
selectedTimeSeries?.values.find(
(value) => value?.timestamp >= (options?.timestamp || 0),
)?.value ?? options?.value
} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`
: selectedTimeSeries?.values.find(
(value) => value?.timestamp >= (options?.timestamp || 0),
)?.value ?? options?.value}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
RAW VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{rawData?.map(({ value: rawValue, timestamp, title }) => (
<Tooltip key={`${title}-${timestamp}-${rawValue}`} title={rawValue}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{rawValue}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIMESTAMPS
</Typography.Text>
<div className="graph-popover-inner-row">
{rawData?.map(({ timestamp }) => (
<Tooltip
key={timestamp}
title={formatTimestampToFullDateTime(timestamp ?? '', true)}
>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{formatTimestampToFullDateTime(timestamp ?? '', true)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Show raw values breakdown only for time aggregated data */}
{selectedTimeSeries && step === InspectionStep.SPACE_AGGREGATION && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
<Typography.Text strong>
{`${absoluteValue} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
RAW VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{timeAggregatedData?.map(({ value, title, timestamp }) => (
<Tooltip key={`${title}-${timestamp}-${value}`} title={value}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{value}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIMESTAMPS
</Typography.Text>
<div className="graph-popover-inner-row">
{timeAggregatedData?.map(({ timestamp }) => (
<Tooltip
key={timestamp}
title={formatTimestampToFullDateTime(timestamp ?? '', true)}
>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{formatTimestampToFullDateTime(timestamp ?? '', true)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Labels */}
{selectedTimeSeries && (
<>
<Typography.Title
level={5}
>{`${selectedTimeSeries?.title} Labels`}</Typography.Title>
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
scroll={{ y: 600 }}
className="labels-table"
/>
</>
)}
</div>
);
}
export default ExpandedView;

View File

@ -0,0 +1,71 @@
import { Button, Card, Typography } from 'antd';
import { ArrowRight } from 'lucide-react';
import { useMemo } from 'react';
import { GraphPopoverProps } from './types';
import { formatTimestampToFullDateTime } from './utils';
function GraphPopover({
options,
popoverRef,
openInExpandedView,
}: GraphPopoverProps): JSX.Element | null {
const { x, y, value, timestamp, timeSeries } = options || {
x: 0,
y: 0,
value: 0,
timestamp: 0,
timeSeries: null,
};
const closestTimestamp = useMemo(() => {
if (!timeSeries) {
return timestamp;
}
return timeSeries?.values.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.timestamp - timestamp);
const currDiff = Math.abs(curr.timestamp - timestamp);
return prevDiff < currDiff ? prev : curr;
}).timestamp;
}, [timeSeries, timestamp]);
const closestValue = useMemo(() => {
if (!timeSeries) {
return value;
}
const index = timeSeries.values.findIndex(
(value) => value.timestamp === closestTimestamp,
);
return index !== undefined && index >= 0
? timeSeries?.values[index].value
: null;
}, [timeSeries, closestTimestamp, value]);
return (
<div
style={{
top: y + 10,
left: x + 10,
}}
ref={popoverRef}
className="inspect-graph-popover"
>
<Card className="inspect-graph-popover-content" size="small">
<div className="inspect-graph-popover-row">
<Typography.Text type="secondary">
{formatTimestampToFullDateTime(closestTimestamp)}
</Typography.Text>
<Typography.Text>{Number(closestValue).toFixed(2)}</Typography.Text>
</div>
<div className="inspect-graph-popover-button-row">
<Button size="small" type="primary" onClick={openInExpandedView}>
<Typography.Text>View details</Typography.Text>
<ArrowRight size={10} />
</Button>
</div>
</Card>
</div>
);
}
export default GraphPopover;

View File

@ -0,0 +1,256 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Skeleton, Switch, Typography } from 'antd';
import Uplot from 'components/Uplot';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { METRIC_TYPE_TO_COLOR_MAP, METRIC_TYPE_TO_ICON_MAP } from './constants';
import GraphPopover from './GraphPopover';
import TableView from './TableView';
import { GraphPopoverOptions, GraphViewProps } from './types';
import { HoverPopover, onGraphClick, onGraphHover } from './utils';
function GraphView({
inspectMetricsTimeSeries,
formattedInspectMetricsTimeSeries,
metricUnit,
metricName,
metricType,
spaceAggregationSeriesMap,
inspectionStep,
setPopoverOptions,
popoverOptions,
setShowExpandedView,
setExpandedViewOptions,
metricInspectionOptions,
isInspectMetricsRefetching,
}: GraphViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const start = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const end = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [maxTime]);
const [showGraphPopover, setShowGraphPopover] = useState(false);
const [showHoverPopover, setShowHoverPopover] = useState(false);
const [
hoverPopoverOptions,
setHoverPopoverOptions,
] = useState<GraphPopoverOptions | null>(null);
const [viewType, setViewType] = useState<'graph' | 'table'>('graph');
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
graphRef.current &&
!graphRef.current.contains(event.target as Node)
) {
setShowGraphPopover(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [popoverRef, graphRef]);
const options: uPlot.Options = useMemo(
() => ({
width: dimensions.width,
height: 500,
legend: {
show: false,
},
axes: [
{
stroke: isDarkMode ? Color.TEXT_VANILLA_400 : Color.BG_SLATE_400,
grid: {
show: false,
},
values: (_, vals): string[] =>
vals.map((v) => {
const d = new Date(v);
const date = `${String(d.getDate()).padStart(2, '0')}/${String(
d.getMonth() + 1,
).padStart(2, '0')}`;
const time = `${String(d.getHours()).padStart(2, '0')}:${String(
d.getMinutes(),
).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
return `${date}\n${time}`; // two-line label
}),
},
{
label: metricUnit || '',
stroke: isDarkMode ? Color.TEXT_VANILLA_400 : Color.BG_SLATE_400,
grid: {
show: true,
stroke: isDarkMode ? Color.BG_SLATE_500 : Color.BG_SLATE_200,
},
values: (_, vals): string[] =>
vals.map((v) => formatNumberIntoHumanReadableFormat(v, false)),
},
],
series: [
{ label: 'Time' }, // This config is required as a placeholder for x-axis,
...formattedInspectMetricsTimeSeries.slice(1).map((_, index) => ({
drawStyle: 'line',
lineInterpolation: 'spline',
show: true,
label: String.fromCharCode(65 + (index % 26)),
stroke: inspectMetricsTimeSeries[index]?.strokeColor,
width: 2,
spanGaps: true,
points: {
size: 5,
show: false,
stroke: inspectMetricsTimeSeries[index]?.strokeColor,
},
scales: {
x: {
min: start,
max: end,
},
},
})),
],
hooks: {
ready: [
(u: uPlot): void => {
u.over.addEventListener('click', (e) => {
onGraphClick(
e,
u,
popoverRef,
setPopoverOptions,
inspectMetricsTimeSeries,
showGraphPopover,
setShowGraphPopover,
formattedInspectMetricsTimeSeries,
);
});
u.over.addEventListener('mousemove', (e) => {
onGraphHover(
e,
u,
setHoverPopoverOptions,
inspectMetricsTimeSeries,
formattedInspectMetricsTimeSeries,
);
});
u.over.addEventListener('mouseenter', () => {
setShowHoverPopover(true);
});
u.over.addEventListener('mouseleave', () => {
setShowHoverPopover(false);
});
},
],
},
}),
[
dimensions.width,
isDarkMode,
metricUnit,
formattedInspectMetricsTimeSeries,
inspectMetricsTimeSeries,
start,
end,
setPopoverOptions,
showGraphPopover,
],
);
const MetricTypeIcon = metricType ? METRIC_TYPE_TO_ICON_MAP[metricType] : null;
return (
<div className="inspect-metrics-graph-view" ref={graphRef}>
<div className="inspect-metrics-graph-view-header">
<Button.Group>
<Button
className="metric-name-button-label"
size="middle"
icon={
MetricTypeIcon && metricType ? (
<MetricTypeIcon
size={14}
color={METRIC_TYPE_TO_COLOR_MAP[metricType]}
/>
) : null
}
disabled
>
{metricName}
</Button>
<Button className="time-series-button-label" size="middle" disabled>
{/* First time series in that of timestamps. Hence -1 */}
{`${formattedInspectMetricsTimeSeries.length - 1} time series`}
</Button>
</Button.Group>
<div className="view-toggle-button">
<Switch
checked={viewType === 'graph'}
onChange={(checked): void => setViewType(checked ? 'graph' : 'table')}
/>
<Typography.Text>
{viewType === 'graph' ? 'Graph View' : 'Table View'}
</Typography.Text>
</div>
</div>
<div className="graph-view-container">
{viewType === 'graph' &&
(isInspectMetricsRefetching ? (
<Skeleton active />
) : (
<Uplot data={formattedInspectMetricsTimeSeries} options={options} />
))}
{viewType === 'table' && (
<TableView
inspectionStep={inspectionStep}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
setShowExpandedView={setShowExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
metricInspectionOptions={metricInspectionOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
)}
</div>
{showGraphPopover && (
<GraphPopover
options={popoverOptions}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
popoverRef={popoverRef}
step={inspectionStep}
openInExpandedView={(): void => {
setShowGraphPopover(false);
setShowExpandedView(true);
setExpandedViewOptions(popoverOptions);
}}
/>
)}
{showHoverPopover && !showGraphPopover && hoverPopoverOptions && (
<HoverPopover
options={hoverPopoverOptions}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
/>
)}
</div>
);
}
export default GraphView;

View File

@ -1,4 +1,14 @@
.inspect-metrics-modal { .inspect-metrics-modal {
display: flex;
gap: 16px;
.inspect-metrics-fallback {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.inspect-metrics-title { .inspect-metrics-title {
display: flex; display: flex;
align-items: center; align-items: center;
@ -13,4 +23,567 @@
color: var(--text-vanilla-500); color: var(--text-vanilla-500);
} }
} }
.inspect-metrics-content {
display: flex;
flex-direction: row;
justify-content: space-between;
.inspect-metrics-content-first-col {
display: flex;
flex-direction: column;
flex: 2;
gap: 16px;
padding-right: 24px;
border-right: 1px solid var(--bg-slate-400);
width: 60%;
.inspect-metrics-graph-view {
display: flex;
flex-direction: column;
gap: 32px;
.inspect-metrics-graph-view-header {
display: flex;
align-items: center;
justify-content: space-between;
.ant-btn-group {
.time-series-button-label,
.metric-name-button-label {
display: flex;
align-items: center;
justify-content: center;
cursor: default;
span {
color: var(--text-vanilla-100);
}
}
}
.view-toggle-button {
display: flex;
gap: 8px;
align-items: center;
}
}
.graph-view-container {
min-height: 520px;
.inspect-metrics-table-view {
max-width: 100%;
.ant-spin-nested-loading {
.ant-spin-container {
.ant-table {
height: 450px;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
}
}
}
.table-view-title-header,
.table-view-values-header {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
.ant-card {
cursor: pointer;
width: 100px;
max-width: 100px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.ant-card-body {
padding: 6px 8px;
}
&:hover {
opacity: 0.8;
}
}
}
}
}
}
.inspect-metrics-query-builder {
display: flex;
flex-direction: column;
gap: 4px;
.inspect-metrics-query-builder-header {
.query-builder-button-label {
display: flex;
align-items: center;
justify-content: center;
cursor: default;
span {
color: var(--text-vanilla-100);
}
}
}
.inspect-metrics-query-builder-content {
.ant-card-body {
display: flex;
flex-direction: column;
gap: 16px;
.selected-step {
color: var(--bg-sakura-500);
.ant-typography {
color: var(--bg-sakura-500);
}
}
.inspect-metrics-input-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
.ant-typography {
min-width: 130px;
}
.ant-select {
flex-grow: 1;
}
.no-arrows-input input[type='number']::-webkit-inner-spin-button,
.no-arrows-input input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Hide number input arrows (Firefox) */
.no-arrows-input input[type='number'] {
appearance: none;
-moz-appearance: textfield;
}
}
.metric-time-aggregation {
display: flex;
flex-direction: column;
gap: 16px;
.metric-time-aggregation-header {
display: flex;
gap: 8px;
}
.metric-time-aggregation-content {
display: flex;
gap: 24px;
width: 100%;
.inspect-metrics-input-group {
width: 50%;
}
}
}
.metric-space-aggregation {
display: flex;
flex-direction: column;
gap: 16px;
.metric-space-aggregation-header {
display: flex;
gap: 8px;
}
.metric-space-aggregation-content {
display: flex;
gap: 8px;
width: 100%;
.metric-space-aggregation-content-left {
width: 130px;
}
}
}
}
}
.metric-filters {
.query-builder-search-container {
width: 100%;
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
color: var(--text-vanilla-100);
border-color: var(--bg-slate-400);
}
}
}
}
}
}
.inspect-metrics-content-second-col {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
.home-checklist-container {
padding-left: 40px;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 32px;
border-bottom: 1px solid var(--bg-slate-400);
.home-checklist-title {
display: flex;
flex-direction: column;
gap: 8px;
}
.completed-checklist-container {
margin-left: 20px;
}
.completed-message-container {
display: flex;
flex-direction: column;
gap: 16px;
height: 100px;
.ant-btn {
width: fit-content;
}
}
}
.expanded-view {
display: flex;
flex-direction: column;
gap: 16px;
padding-left: 40px;
}
}
}
}
.inspect-graph-popover {
position: fixed;
z-index: 1000;
.inspect-graph-popover-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 350px;
.inspect-graph-popover-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.inspect-graph-popover-button-row {
display: flex;
align-items: center;
justify-content: flex-end;
.ant-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 16px;
}
}
}
}
.graph-popover {
position: fixed;
z-index: 1000;
.graph-popover-card {
width: 550px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
.ant-card-body {
width: fit-content;
}
.graph-popover-row {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
.graph-popover-row-label {
width: 100px;
}
.graph-popover-inner-row {
display: flex;
align-items: center;
gap: 8px;
.ant-typography {
width: 400px;
margin-top: 4px;
align-items: center;
display: flex;
gap: 8px;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
text-overflow: ellipsis;
}
}
}
.graph-popover-header-text {
color: var(--text-vanilla-400);
}
.graph-popover-row-label {
color: var(--bg-slate-50);
width: 10%;
}
.graph-popover-cell {
padding: 4px 8px;
background-color: #1f1f1f;
border-radius: 4px;
color: #fff;
min-width: 60px;
max-width: 60px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.footer-row {
margin-top: 12px;
display: flex;
gap: 8px;
align-items: center;
.footer-text {
white-space: nowrap;
}
.footer-divider {
flex: 1;
border-top: 1px dashed #ccc;
margin: 0 8px;
}
}
}
}
.expanded-view {
.expanded-view-header {
.ant-typography {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
}
.graph-popover {
z-index: 2;
position: initial;
.graph-popover-card {
width: 100%;
.timeseries-cell {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
opacity: 60%;
}
}
.selected {
opacity: 90%;
}
.graph-popover-section {
width: 500px;
overflow-x: scroll;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
text-overflow: ellipsis;
.graph-popover-row {
.graph-popover-row-label {
min-width: 100px;
}
.graph-popover-inner-row {
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
}
.labels-table {
border: 1px solid var(--bg-slate-400);
.labels-key {
color: var(--bg-vanilla-400);
background-color: var(--bg-slate-500);
font-family: 'Geist Mono';
}
.labels-value {
background-color: var(--bg-slate-500);
opacity: 80%;
font-family: 'Geist Mono';
.field-renderer-container {
.label {
color: var(--bg-slate-400);
}
}
}
}
}
.hover-popover-card {
position: fixed;
z-index: 500;
max-width: 700px;
display: flex;
flex-direction: column;
gap: 8px;
.hover-popover-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
.lightMode {
.inspect-metrics-modal {
.inspect-metrics-title {
.inspect-metrics-button {
color: var(--text-ink-400);
}
}
.inspect-metrics-content {
.inspect-metrics-content-first-col {
.inspect-metrics-graph-view {
.inspect-metrics-graph-view-header {
.ant-btn-group {
.time-series-button-label,
.metric-name-button-label {
span {
color: var(--text-ink-100);
}
}
}
}
}
.inspect-metrics-query-builder {
.inspect-metrics-query-builder-header {
.query-builder-button-label {
span {
color: var(--text-ink-100);
}
}
}
.metric-filters {
.query-builder-search-v2 {
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
border: 0.5px solid var(--bg-slate-300) !important;
}
}
}
}
}
}
}
}
.graph-popover {
.graph-popover-card {
.graph-popover-header-text {
color: var(--text-ink-400);
}
.graph-popover-row-label {
color: var(--bg-slate-50);
}
.graph-popover-cell {
background-color: var(--bg-vanilla-300);
color: var(--text-ink-100);
}
.footer-row {
.footer-divider {
border-top: 1px dashed var(--bg-slate-300);
}
}
}
}
.expanded-view {
.labels-table {
border: 1px solid var(--bg-vanilla-400);
.labels-key {
color: var(--bg-slate-400);
background-color: var(--bg-vanilla-400);
}
.labels-value {
background-color: var(--bg-vanilla-400);
.field-renderer-container {
.label {
color: var(--bg-vanilla-400);
}
}
}
}
}
} }

View File

@ -2,15 +2,236 @@ import './Inspect.styles.scss';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Button, Drawer, Typography } from 'antd'; import { Button, Drawer, Empty, Skeleton, Typography } from 'antd';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass } from 'lucide-react'; import { Compass } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { InspectProps } from './types'; import ExpandedView from './ExpandedView';
import GraphView from './GraphView';
import QueryBuilder from './QueryBuilder';
import Stepper from './Stepper';
import { GraphPopoverOptions, InspectProps } from './types';
import { useInspectMetrics } from './useInspectMetrics';
function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element { function Inspect({
metricName: defaultMetricName,
isOpen,
onClose,
}: InspectProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const [metricName, setMetricName] = useState<string | null>(defaultMetricName);
const [
popoverOptions,
setPopoverOptions,
] = useState<GraphPopoverOptions | null>(null);
const [
expandedViewOptions,
setExpandedViewOptions,
] = useState<GraphPopoverOptions | null>(null);
const [showExpandedView, setShowExpandedView] = useState(false);
const { data: metricDetailsData } = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
});
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
},
],
},
}),
[currentQuery],
);
const searchQuery = updatedCurrentQuery?.builder?.queryData[0] || null;
useEffect(() => {
handleChangeQueryData('filters', {
op: 'AND',
items: [],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const {
inspectMetricsTimeSeries,
inspectMetricsStatusCode,
isInspectMetricsLoading,
isInspectMetricsError,
formattedInspectMetricsTimeSeries,
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
isInspectMetricsRefetching,
spaceAggregatedSeriesMap: spaceAggregationSeriesMap,
aggregatedTimeSeries,
timeAggregatedSeriesMap,
reset,
} = useInspectMetrics(metricName);
const selectedMetricType = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.metric_type,
[metricDetailsData],
);
const selectedMetricUnit = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.unit,
[metricDetailsData],
);
const resetInspection = useCallback(() => {
setShowExpandedView(false);
setPopoverOptions(null);
setExpandedViewOptions(null);
reset();
}, [reset]);
// Reset inspection when the selected metric changes
useEffect(() => {
resetInspection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricName]);
// Hide expanded view whenever inspection step changes
useEffect(() => {
setShowExpandedView(false);
setExpandedViewOptions(null);
}, [inspectionStep]);
const content = useMemo(() => {
if (isInspectMetricsLoading && !isInspectMetricsRefetching) {
return (
<div
data-testid="inspect-metrics-loading"
className="inspect-metrics-fallback"
>
<Skeleton active />
</div>
);
}
if (isInspectMetricsError || inspectMetricsStatusCode !== 200) {
const errorMessage =
inspectMetricsStatusCode === 400
? 'The time range is too large. Please modify it to be within 30 minutes.'
: 'Error loading inspect metrics.';
return (
<div
data-testid="inspect-metrics-error"
className="inspect-metrics-fallback"
>
<Empty description={errorMessage} />
</div>
);
}
if (!inspectMetricsTimeSeries.length) {
return (
<div
data-testid="inspect-metrics-empty"
className="inspect-metrics-fallback"
>
<Empty description="No time series found for this metric to inspect." />
</div>
);
}
return (
<div className="inspect-metrics-content">
<div className="inspect-metrics-content-first-col">
<GraphView
inspectMetricsTimeSeries={aggregatedTimeSeries}
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
resetInspection={resetInspection}
metricName={metricName}
metricUnit={selectedMetricUnit}
metricType={selectedMetricType}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
inspectionStep={inspectionStep}
setPopoverOptions={setPopoverOptions}
setShowExpandedView={setShowExpandedView}
showExpandedView={showExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
popoverOptions={popoverOptions}
metricInspectionOptions={metricInspectionOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
<QueryBuilder
metricName={metricName}
metricType={selectedMetricType}
setMetricName={setMetricName}
spaceAggregationLabels={spaceAggregationLabels}
metricInspectionOptions={metricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
inspectionStep={inspectionStep}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
searchQuery={searchQuery}
/>
</div>
<div className="inspect-metrics-content-second-col">
<Stepper
inspectionStep={inspectionStep}
resetInspection={resetInspection}
/>
{showExpandedView && (
<ExpandedView
options={expandedViewOptions}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
/>
)}
</div>
</div>
);
}, [
isInspectMetricsLoading,
isInspectMetricsRefetching,
isInspectMetricsError,
inspectMetricsStatusCode,
inspectMetricsTimeSeries,
aggregatedTimeSeries,
formattedInspectMetricsTimeSeries,
resetInspection,
metricName,
selectedMetricUnit,
selectedMetricType,
spaceAggregationSeriesMap,
inspectionStep,
showExpandedView,
popoverOptions,
metricInspectionOptions,
spaceAggregationLabels,
dispatchMetricInspectionOptions,
searchQuery,
expandedViewOptions,
timeAggregatedSeriesMap,
]);
return ( return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}> <Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
@ -38,8 +259,7 @@ function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element {
className="inspect-metrics-modal" className="inspect-metrics-modal"
destroyOnClose destroyOnClose
> >
<div>Inspect</div> {content}
<div>{metricName}</div>
</Drawer> </Drawer>
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
); );

View File

@ -0,0 +1,60 @@
import { Button, Card } from 'antd';
import { Atom } from 'lucide-react';
import { QueryBuilderProps } from './types';
import {
MetricFilters,
MetricNameSearch,
MetricSpaceAggregation,
MetricTimeAggregation,
} from './utils';
function QueryBuilder({
metricName,
setMetricName,
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
inspectMetricsTimeSeries,
searchQuery,
metricType,
}: QueryBuilderProps): JSX.Element {
return (
<div className="inspect-metrics-query-builder">
<div className="inspect-metrics-query-builder-header">
<Button
className="query-builder-button-label"
size="middle"
icon={<Atom size={14} />}
disabled
>
Query Builder
</Button>
</div>
<Card className="inspect-metrics-query-builder-content">
<MetricNameSearch metricName={metricName} setMetricName={setMetricName} />
<MetricFilters
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
searchQuery={searchQuery}
metricName={metricName}
metricType={metricType || null}
/>
<MetricTimeAggregation
inspectionStep={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
/>
<MetricSpaceAggregation
inspectionStep={inspectionStep}
spaceAggregationLabels={spaceAggregationLabels}
metricInspectionOptions={metricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
/>
</Card>
</div>
);
}
export default QueryBuilder;

View File

@ -0,0 +1,92 @@
import '../../Home/HomeChecklist/HomeChecklist.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Typography } from 'antd';
import classNames from 'classnames';
import { ArrowUpRightFromSquare, RefreshCcw } from 'lucide-react';
import { SPACE_AGGREGATION_LINK, TEMPORAL_AGGREGATION_LINK } from './constants';
import { InspectionStep, StepperProps } from './types';
function Stepper({
inspectionStep,
resetInspection,
}: StepperProps): JSX.Element {
return (
<div className="home-checklist-container">
<div className="home-checklist-title">
<Typography.Text>
👋 Hello, welcome to the Metrics Inspector
</Typography.Text>
<Typography.Text>Lets get you started...</Typography.Text>
</div>
<div className="completed-checklist-container whats-next-checklist-container">
<div
className={classNames({
'completed-checklist-item':
inspectionStep > InspectionStep.TIME_AGGREGATION,
'whats-next-checklist-item':
inspectionStep <= InspectionStep.TIME_AGGREGATION,
})}
>
<div
className={classNames({
'completed-checklist-item-title':
inspectionStep > InspectionStep.TIME_AGGREGATION,
'whats-next-checklist-item-title':
inspectionStep <= InspectionStep.TIME_AGGREGATION,
})}
>
First, align the data by selecting a{' '}
<Typography.Link href={TEMPORAL_AGGREGATION_LINK} target="_blank">
Temporal Aggregation{' '}
<ArrowUpRightFromSquare color={Color.BG_ROBIN_500} size={10} />
</Typography.Link>
</div>
</div>
<div
className={classNames({
'completed-checklist-item':
inspectionStep > InspectionStep.SPACE_AGGREGATION,
'whats-next-checklist-item':
inspectionStep <= InspectionStep.SPACE_AGGREGATION,
})}
>
<div
className={classNames({
'completed-checklist-item-title':
inspectionStep > InspectionStep.SPACE_AGGREGATION,
'whats-next-checklist-item-title':
inspectionStep <= InspectionStep.SPACE_AGGREGATION,
})}
>
Add a{' '}
<Typography.Link href={SPACE_AGGREGATION_LINK} target="_blank">
Spatial Aggregation{' '}
<ArrowUpRightFromSquare color={Color.BG_ROBIN_500} size={10} />
</Typography.Link>
</div>
</div>
</div>
<div className="completed-message-container">
{inspectionStep === InspectionStep.COMPLETED && (
<>
<Typography.Text>
🎉 Ta-da! You have completed your metric query tutorial.
</Typography.Text>
<Typography.Text>
You can inspect a new metric or reset the query builder.
</Typography.Text>
<Button icon={<RefreshCcw size={12} />} onClick={resetInspection}>
Reset query
</Button>
</>
)}
</div>
</div>
);
}
export default Stepper;

View File

@ -0,0 +1,136 @@
import { Card, Flex, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { useCallback, useMemo } from 'react';
import { TableViewProps } from './types';
import { formatTimestampToFullDateTime } from './utils';
function TableView({
inspectMetricsTimeSeries,
setShowExpandedView,
setExpandedViewOptions,
isInspectMetricsRefetching,
metricInspectionOptions,
}: TableViewProps): JSX.Element {
const isSpaceAggregatedWithoutLabel = useMemo(
() =>
!!metricInspectionOptions.spaceAggregationOption &&
metricInspectionOptions.spaceAggregationLabels.length === 0,
[metricInspectionOptions],
);
const labelKeys = useMemo(() => {
if (isSpaceAggregatedWithoutLabel) {
return [];
}
if (inspectMetricsTimeSeries.length > 0) {
return Object.keys(inspectMetricsTimeSeries[0].labels);
}
return [];
}, [inspectMetricsTimeSeries, isSpaceAggregatedWithoutLabel]);
const getDynamicColumnStyle = (strokeColor?: string): React.CSSProperties => {
const style: React.CSSProperties = {
maxWidth: '200px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
if (strokeColor) {
style.color = strokeColor;
}
return style;
};
const columns = useMemo(
() => [
...labelKeys.map((label) => ({
title: label,
dataIndex: label,
align: 'left',
render: (text: string): JSX.Element => (
<div style={getDynamicColumnStyle()}>{text}</div>
),
})),
{
title: 'Values',
dataIndex: 'values',
align: 'left',
sticky: 'right',
},
],
[labelKeys],
);
const openExpandedView = useCallback(
(series: InspectMetricsSeries, value: string, timestamp: number): void => {
setShowExpandedView(true);
setExpandedViewOptions({
x: timestamp,
y: Number(value),
value: Number(value),
timestamp,
timeSeries: series,
});
},
[setShowExpandedView, setExpandedViewOptions],
);
const dataSource = useMemo(
() =>
inspectMetricsTimeSeries.map((series, index) => {
const labelData = labelKeys.reduce((acc, label) => {
acc[label] = (
<div style={getDynamicColumnStyle(series.strokeColor)}>
{series.labels[label]}
</div>
);
return acc;
}, {} as Record<string, JSX.Element>);
return {
key: index,
...labelData,
values: (
<div className="table-view-values-header">
<Flex gap={8}>
{series.values.map((value) => {
const formattedValue = `(${formatTimestampToFullDateTime(
value.timestamp,
true,
)}, ${value.value})`;
return (
<Card
key={formattedValue}
onClick={(): void =>
openExpandedView(series, value.value, value.timestamp)
}
>
<Typography.Text>{formattedValue}</Typography.Text>
</Card>
);
})}
</Flex>
</div>
),
};
}),
[inspectMetricsTimeSeries, labelKeys, openExpandedView],
);
return (
<Table
className="inspect-metrics-table-view"
dataSource={dataSource}
columns={
columns as ColumnsType<{
values: JSX.Element;
key: number;
}>
}
scroll={{ x: '100%' }}
loading={isInspectMetricsRefetching}
/>
);
}
export default TableView;

View File

@ -0,0 +1,166 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import {
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
TIME_AGGREGATION_OPTIONS,
} from '../constants';
import ExpandedView from '../ExpandedView';
import {
GraphPopoverData,
InspectionStep,
MetricInspectionOptions,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
describe('ExpandedView', () => {
const mockTimeSeries: InspectMetricsSeries = {
values: [
{ timestamp: 1672531200000, value: '42.123' },
{ timestamp: 1672531260000, value: '43.456' },
{ timestamp: 1672531320000, value: '44.789' },
{ timestamp: 1672531380000, value: '45.012' },
],
labels: {
host_id: 'test-id',
},
labelsArray: [],
title: 'TS1',
};
const mockOptions = {
x: 100,
y: 100,
value: 42.123,
timestamp: 1672531200000,
timeSeries: mockTimeSeries,
};
const mockSpaceAggregationSeriesMap = new Map<string, InspectMetricsSeries[]>([
['host_id:test-id', [mockTimeSeries]],
]);
const mockTimeAggregatedSeriesMap = new Map<number, GraphPopoverData[]>([
[
1672531200000,
[
{
value: '42.123',
type: 'instance',
timestamp: 1672531200000,
title: 'TS1',
},
{
value: '43.456',
type: 'instance',
timestamp: 1672531260000,
title: 'TS1',
},
],
],
]);
const mockMetricInspectionOptions: MetricInspectionOptions = {
timeAggregationOption: TimeAggregationOptions.MAX,
timeAggregationInterval: 60,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
filters: {
items: [],
op: 'AND',
},
};
it('renders entire time series for a raw data inspection', () => {
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.TIME_AGGREGATION}
metricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
const graphPopoverCells = screen.getAllByTestId('graph-popover-cell');
expect(graphPopoverCells).toHaveLength(mockTimeSeries.values.length * 2);
expect(screen.getAllByText('42.123')).toHaveLength(2);
});
it('renders correct split data for a time aggregation inspection', () => {
const TIME_AGGREGATION_INTERVAL = 120;
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.SPACE_AGGREGATION}
metricInspectionOptions={{
...mockMetricInspectionOptions,
timeAggregationInterval: TIME_AGGREGATION_INTERVAL,
}}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
// time series by default has values at 60 seconds
// by doing time aggregation at 120 seconds, we should have 2 values
const graphPopoverCells = screen.getAllByTestId('graph-popover-cell');
expect(graphPopoverCells).toHaveLength((TIME_AGGREGATION_INTERVAL / 60) * 2);
expect(
screen.getByText(
`42.123 is the ${
TIME_AGGREGATION_OPTIONS[
mockMetricInspectionOptions.timeAggregationOption as TimeAggregationOptions
]
} of`,
),
);
expect(screen.getByText('42.123')).toBeInTheDocument();
expect(screen.getByText('43.456')).toBeInTheDocument();
});
it('renders all child time series for a space aggregation inspection', () => {
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.COMPLETED}
metricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
const graphPopoverCells = screen.getAllByTestId('graph-popover-cell');
expect(graphPopoverCells).toHaveLength(
mockSpaceAggregationSeriesMap.size * 2,
);
expect(
screen.getByText(
`42.123 is the ${
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW[
mockMetricInspectionOptions.spaceAggregationOption as SpaceAggregationOptions
]
} of`,
),
).toBeInTheDocument();
expect(screen.getByText('TS1')).toBeInTheDocument();
});
it('renders all labels for the selected time series', () => {
render(
<ExpandedView
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.TIME_AGGREGATION}
metricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
expect(
screen.getByText(`${mockTimeSeries.title} Labels`),
).toBeInTheDocument();
expect(screen.getByText('host_id')).toBeInTheDocument();
expect(screen.getByText('test-id')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,82 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import GraphPopover from '../GraphPopover';
import { GraphPopoverOptions, InspectionStep } from '../types';
describe('GraphPopover', () => {
const mockOptions: GraphPopoverOptions = {
x: 100,
y: 100,
value: 42.123,
timestamp: 1672531200000,
timeSeries: {
values: [
{ timestamp: 1672531200000, value: '42.123' },
{ timestamp: 1672531260000, value: '43.456' },
],
labels: {},
labelsArray: [],
},
};
const mockSpaceAggregationSeriesMap: Map<
string,
InspectMetricsSeries[]
> = new Map();
const mockOpenInExpandedView = jest.fn();
const mockStep = InspectionStep.TIME_AGGREGATION;
it('renders with correct values', () => {
render(
<GraphPopover
options={mockOptions}
popoverRef={{ current: null }}
openInExpandedView={mockOpenInExpandedView}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={mockStep}
/>,
);
// Check value is rendered with 2 decimal places
expect(screen.getByText('42.12')).toBeInTheDocument();
});
it('opens the expanded view when button is clicked', () => {
render(
<GraphPopover
options={mockOptions}
popoverRef={{ current: null }}
openInExpandedView={mockOpenInExpandedView}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={mockStep}
/>,
);
const button = screen.getByText('View details');
fireEvent.click(button);
expect(mockOpenInExpandedView).toHaveBeenCalledTimes(1);
});
it('finds closest timestamp and value from timeSeries', () => {
const optionsWithOffset: GraphPopoverOptions = {
...mockOptions,
timestamp: 1672531230000,
value: 42.24,
};
render(
<GraphPopover
options={optionsWithOffset}
popoverRef={{ current: null }}
openInExpandedView={mockOpenInExpandedView}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={mockStep}
/>,
);
// Should show the closest value
expect(screen.getByText('43.46')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,113 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { Provider } from 'react-redux';
import store from 'store';
import { AlignedData } from 'uplot';
import GraphView from '../GraphView';
import {
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: jest.fn(),
})),
);
const mockResizeObserver = jest.fn();
mockResizeObserver.mockImplementation(() => ({
observe: (): void => undefined,
unobserve: (): void => undefined,
disconnect: (): void => undefined,
}));
window.ResizeObserver = mockResizeObserver;
describe('GraphView', () => {
const mockTimeSeries: InspectMetricsSeries[] = [
{
strokeColor: '#000',
title: 'Series 1',
values: [
{ timestamp: 1234567890000, value: '10' },
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [{ label: 'label1', value: 'value1' }],
},
];
const defaultProps = {
inspectMetricsTimeSeries: mockTimeSeries,
formattedInspectMetricsTimeSeries: [
[1, 2],
[1, 2],
] as AlignedData,
metricUnit: '',
metricName: 'test_metric',
metricType: MetricType.GAUGE,
spaceAggregationSeriesMap: new Map(),
inspectionStep: InspectionStep.COMPLETED,
setPopoverOptions: jest.fn(),
popoverOptions: null,
setShowExpandedView: jest.fn(),
setExpandedViewOptions: jest.fn(),
resetInspection: jest.fn(),
showExpandedView: false,
metricInspectionOptions: {
timeAggregationInterval: 60,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
timeAggregationOption: TimeAggregationOptions.MAX,
filters: {
items: [],
op: 'AND',
},
},
isInspectMetricsRefetching: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders graph view by default', () => {
render(
<Provider store={store}>
<GraphView {...defaultProps} />
</Provider>,
);
expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByText('Graph View')).toBeInTheDocument();
});
it('switches between graph and table view', async () => {
render(
<Provider store={store}>
<GraphView {...defaultProps} />
</Provider>,
);
const switchButton = screen.getByRole('switch');
expect(screen.getByText('Graph View')).toBeInTheDocument();
await userEvent.click(switchButton);
expect(screen.getByText('Table View')).toBeInTheDocument();
});
it('renders metric name and number of series', () => {
render(
<Provider store={store}>
<GraphView {...defaultProps} />
</Provider>,
);
expect(screen.getByText('test_metric')).toBeInTheDocument();
expect(screen.getByText('1 time series')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,198 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useInspectMetricsHooks from 'hooks/metricsExplorer/useGetInspectMetricsDetails';
import * as useGetMetricDetailsHooks from 'hooks/metricsExplorer/useGetMetricDetails';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store from 'store';
import ROUTES from '../../../../constants/routes';
import Inspect from '../Inspect';
import { InspectionStep } from '../types';
const queryClient = new QueryClient();
const mockTimeSeries: InspectMetricsSeries[] = [
{
strokeColor: '#000',
title: 'Series 1',
values: [
{ timestamp: 1234567890000, value: '10' },
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [{ label: 'label1', value: 'value1' }],
},
];
jest.spyOn(useGetMetricDetailsHooks, 'useGetMetricDetails').mockReturnValue({
data: {
metricDetails: {
metricName: 'test_metric',
metricType: MetricType.GAUGE,
},
},
} as any);
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: mockTimeSeries,
},
status: 'success',
},
},
isLoading: false,
} as any);
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: jest.fn(),
})),
);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
const mockResizeObserver = jest.fn();
mockResizeObserver.mockImplementation(() => ({
observe: (): void => undefined,
unobserve: (): void => undefined,
disconnect: (): void => undefined,
}));
window.ResizeObserver = mockResizeObserver;
describe('Inspect', () => {
const defaultProps = {
inspectMetricsTimeSeries: mockTimeSeries,
formattedInspectMetricsTimeSeries: [],
metricUnit: '',
metricName: 'test_metric',
metricType: MetricType.GAUGE,
spaceAggregationSeriesMap: new Map(),
inspectionStep: InspectionStep.COMPLETED,
resetInspection: jest.fn(),
isOpen: true,
onClose: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all components', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByText('test_metric')).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeInTheDocument(); // Graph/Table view switch
expect(screen.getByText('Query Builder')).toBeInTheDocument();
});
it('renders loading state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: true,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-loading')).toBeInTheDocument();
});
it('renders empty state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: false,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-empty')).toBeInTheDocument();
});
it('renders error state', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
payload: {
data: {
series: [],
},
},
},
isLoading: false,
isError: true,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-error')).toBeInTheDocument();
});
it('renders error state with 400 status code', () => {
jest
.spyOn(useInspectMetricsHooks, 'useGetInspectMetricsDetails')
.mockReturnValue({
data: {
statusCode: 400,
},
isError: false,
} as any);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Inspect {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('inspect-metrics-error')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,110 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store from 'store';
import ROUTES from '../../../../constants/routes';
import QueryBuilder from '../QueryBuilder';
import {
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
const queryClient = new QueryClient();
describe('QueryBuilder', () => {
const defaultProps = {
metricName: 'test_metric',
setMetricName: jest.fn(),
spaceAggregationLabels: ['label1', 'label2'],
metricInspectionOptions: {
timeAggregationInterval: 60,
timeAggregationOption: TimeAggregationOptions.AVG,
spaceAggregationLabels: [],
spaceAggregationOption: SpaceAggregationOptions.AVG_BY,
filters: {
items: [],
op: 'and',
},
},
dispatchMetricInspectionOptions: jest.fn(),
metricType: MetricType.SUM,
inspectionStep: InspectionStep.TIME_AGGREGATION,
inspectMetricsTimeSeries: [],
searchQuery: {
filters: {
items: [],
op: 'and',
},
} as any,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders query builder header', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
});
it('renders metric name search component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-name-search')).toBeInTheDocument();
});
it('renders metric filters component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-filters')).toBeInTheDocument();
});
it('renders time aggregation component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-time-aggregation')).toBeInTheDocument();
});
it('renders space aggregation component', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
expect(screen.getByTestId('metric-space-aggregation')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Stepper from '../Stepper';
import { InspectionStep } from '../types';
describe('Stepper', () => {
const mockResetInspection = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders welcome message', () => {
render(
<Stepper
inspectionStep={InspectionStep.TIME_AGGREGATION}
resetInspection={mockResetInspection}
/>,
);
expect(
screen.getByText('👋 Hello, welcome to the Metrics Inspector'),
).toBeInTheDocument();
});
it('shows temporal aggregation step as active when on first step', () => {
render(
<Stepper
inspectionStep={InspectionStep.TIME_AGGREGATION}
resetInspection={mockResetInspection}
/>,
);
const temporalStep = screen.getByText(/First, align the data by selecting a/);
expect(temporalStep.parentElement).toHaveClass('whats-next-checklist-item');
});
it('shows temporal aggregation step as completed when on later steps', () => {
render(
<Stepper
inspectionStep={InspectionStep.SPACE_AGGREGATION}
resetInspection={mockResetInspection}
/>,
);
const temporalStep = screen.getByText(/First, align the data by selecting a/);
expect(temporalStep.parentElement).toHaveClass('completed-checklist-item');
});
it('calls resetInspection when reset button is clicked', async () => {
render(
<Stepper
inspectionStep={InspectionStep.COMPLETED}
resetInspection={mockResetInspection}
/>,
);
const resetButton = screen.getByRole('button');
await userEvent.click(resetButton);
expect(mockResetInspection).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,100 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import TableView from '../TableView';
import {
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
import { formatTimestampToFullDateTime } from '../utils';
describe('TableView', () => {
const mockTimeSeries: InspectMetricsSeries[] = [
{
strokeColor: '#000',
title: 'Series 1',
values: [
{ timestamp: 1234567890000, value: '10' },
{ timestamp: 1234567891000, value: '20' },
],
labels: { label1: 'value1' },
labelsArray: [
{
label: 'label1',
value: 'value1',
},
],
},
{
strokeColor: '#fff',
title: 'Series 2',
values: [
{ timestamp: 1234567890000, value: '30' },
{ timestamp: 1234567891000, value: '40' },
],
labels: { label2: 'value2' },
labelsArray: [
{
label: 'label2',
value: 'value2',
},
],
},
];
const defaultProps = {
inspectionStep: InspectionStep.COMPLETED,
inspectMetricsTimeSeries: mockTimeSeries,
setShowExpandedView: jest.fn(),
setExpandedViewOptions: jest.fn(),
metricInspectionOptions: {
timeAggregationInterval: 60,
timeAggregationOption: TimeAggregationOptions.MAX,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],
filters: {
items: [],
op: 'AND',
},
},
isInspectMetricsRefetching: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders table with correct columns', () => {
render(<TableView {...defaultProps} />);
expect(screen.getByText('label1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('Values')).toBeInTheDocument();
});
it('renders time series titles correctly when inspection is completed', () => {
render(<TableView {...defaultProps} />);
expect(screen.getByText('label1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
});
it('renders time series values in correct format', () => {
render(<TableView {...defaultProps} />);
const formattedValues = mockTimeSeries.map(
(series) =>
series.values.map(
(v) => `(${formatTimestampToFullDateTime(v.timestamp, true)}, ${v.value})`,
)[0],
);
formattedValues.forEach((value) => {
expect(screen.getByText(value, { exact: false })).toBeInTheDocument();
});
});
it('applies correct styling to time series titles', () => {
render(<TableView {...defaultProps} />);
const titles = screen.getByText('value1');
expect(titles).toHaveStyle({ color: mockTimeSeries[0].strokeColor });
});
});

View File

@ -1 +1,91 @@
import { Color } from '@signozhq/design-tokens';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
LucideProps,
} from 'lucide-react';
import { ForwardRefExoticComponent, RefAttributes } from 'react';
import {
MetricInspectionOptions,
SpaceAggregationOptions,
TimeAggregationOptions,
} from './types';
export const INSPECT_FEATURE_FLAG_KEY = 'metrics-explorer-inspect-feature-flag'; export const INSPECT_FEATURE_FLAG_KEY = 'metrics-explorer-inspect-feature-flag';
export const METRIC_TYPE_TO_COLOR_MAP: Record<MetricType, string> = {
[MetricType.GAUGE]: Color.BG_SAKURA_500,
[MetricType.HISTOGRAM]: Color.BG_SIENNA_500,
[MetricType.SUM]: Color.BG_ROBIN_500,
[MetricType.SUMMARY]: Color.BG_FOREST_500,
[MetricType.EXPONENTIAL_HISTOGRAM]: Color.BG_AQUA_500,
};
export const METRIC_TYPE_TO_ICON_MAP: Record<
MetricType,
ForwardRefExoticComponent<
Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>
>
> = {
[MetricType.GAUGE]: Gauge,
[MetricType.HISTOGRAM]: BarChart2,
[MetricType.SUM]: Diff,
[MetricType.SUMMARY]: BarChartHorizontal,
[MetricType.EXPONENTIAL_HISTOGRAM]: BarChart,
};
export const TIME_AGGREGATION_OPTIONS: Record<
TimeAggregationOptions,
string
> = {
[TimeAggregationOptions.LATEST]: 'Latest',
[TimeAggregationOptions.SUM]: 'Sum',
[TimeAggregationOptions.AVG]: 'Avg',
[TimeAggregationOptions.MIN]: 'Min',
[TimeAggregationOptions.MAX]: 'Max',
[TimeAggregationOptions.COUNT]: 'Count',
};
export const SPACE_AGGREGATION_OPTIONS: Record<
SpaceAggregationOptions,
string
> = {
[SpaceAggregationOptions.SUM_BY]: 'Sum by',
[SpaceAggregationOptions.MIN_BY]: 'Min by',
[SpaceAggregationOptions.MAX_BY]: 'Max by',
[SpaceAggregationOptions.AVG_BY]: 'Avg by',
};
export const SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW: Record<
SpaceAggregationOptions,
string
> = {
[SpaceAggregationOptions.SUM_BY]: 'Sum',
[SpaceAggregationOptions.MIN_BY]: 'Min',
[SpaceAggregationOptions.MAX_BY]: 'Max',
[SpaceAggregationOptions.AVG_BY]: 'Avg',
};
export const INITIAL_INSPECT_METRICS_OPTIONS: MetricInspectionOptions = {
timeAggregationOption: undefined,
timeAggregationInterval: undefined,
spaceAggregationOption: undefined,
spaceAggregationLabels: [],
filters: {
items: [],
op: 'AND',
},
};
export const TEMPORAL_AGGREGATION_LINK =
'https://signoz.io/docs/metrics-management/types-and-aggregation/#step-2-temporal-aggregation';
export const SPACE_AGGREGATION_LINK =
'https://signoz.io/docs/metrics-management/types-and-aggregation/#step-3-spatial-aggregation';
export const GRAPH_CLICK_PIXEL_TOLERANCE = 10;

View File

@ -1,5 +1,176 @@
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { AlignedData } from 'uplot';
export type InspectProps = { export type InspectProps = {
metricName: string | null; metricName: string | null;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}; };
export interface UseInspectMetricsReturnData {
inspectMetricsTimeSeries: InspectMetricsSeries[];
inspectMetricsStatusCode: number;
isInspectMetricsLoading: boolean;
isInspectMetricsError: boolean;
formattedInspectMetricsTimeSeries: AlignedData;
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
isInspectMetricsRefetching: boolean;
spaceAggregatedSeriesMap: Map<string, InspectMetricsSeries[]>;
aggregatedTimeSeries: InspectMetricsSeries[];
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
reset: () => void;
}
export interface GraphViewProps {
inspectMetricsTimeSeries: InspectMetricsSeries[];
metricUnit: string | undefined;
metricName: string | null;
metricType?: MetricType | undefined;
formattedInspectMetricsTimeSeries: AlignedData;
resetInspection: () => void;
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
inspectionStep: InspectionStep;
setPopoverOptions: (options: GraphPopoverOptions | null) => void;
popoverOptions: GraphPopoverOptions | null;
showExpandedView: boolean;
setShowExpandedView: (showExpandedView: boolean) => void;
setExpandedViewOptions: (options: GraphPopoverOptions | null) => void;
metricInspectionOptions: MetricInspectionOptions;
isInspectMetricsRefetching: boolean;
}
export interface QueryBuilderProps {
metricName: string | null;
setMetricName: (metricName: string) => void;
metricType: MetricType | undefined;
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
searchQuery: IBuilderQuery;
}
export interface MetricNameSearchProps {
metricName: string | null;
setMetricName: (metricName: string) => void;
}
export interface MetricFiltersProps {
searchQuery: IBuilderQuery;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
metricName: string | null;
metricType: MetricType | null;
}
export interface MetricTimeAggregationProps {
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
}
export interface MetricSpaceAggregationProps {
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
}
export enum TimeAggregationOptions {
LATEST = 'latest',
SUM = 'sum',
AVG = 'avg',
MIN = 'min',
MAX = 'max',
COUNT = 'count',
}
export enum SpaceAggregationOptions {
SUM_BY = 'sum_by',
MIN_BY = 'min_by',
MAX_BY = 'max_by',
AVG_BY = 'avg_by',
}
export interface MetricInspectionOptions {
timeAggregationOption: TimeAggregationOptions | undefined;
timeAggregationInterval: number | undefined;
spaceAggregationOption: SpaceAggregationOptions | undefined;
spaceAggregationLabels: string[];
filters: TagFilter;
}
export type MetricInspectionAction =
| { type: 'SET_TIME_AGGREGATION_OPTION'; payload: TimeAggregationOptions }
| { type: 'SET_TIME_AGGREGATION_INTERVAL'; payload: number }
| { type: 'SET_SPACE_AGGREGATION_OPTION'; payload: SpaceAggregationOptions }
| { type: 'SET_SPACE_AGGREGATION_LABELS'; payload: string[] }
| { type: 'SET_FILTERS'; payload: TagFilter }
| { type: 'RESET_INSPECTION' };
export enum InspectionStep {
TIME_AGGREGATION = 1,
SPACE_AGGREGATION = 2,
COMPLETED = 3,
}
export interface StepperProps {
inspectionStep: InspectionStep;
resetInspection: () => void;
}
export interface GraphPopoverOptions {
x: number;
y: number;
value: number;
timestamp: number;
timeSeries: InspectMetricsSeries | undefined;
}
export interface GraphPopoverProps {
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
options: GraphPopoverOptions | null;
popoverRef: React.RefObject<HTMLDivElement>;
step: InspectionStep;
openInExpandedView: () => void;
}
export interface GraphPopoverData {
timestamp?: number;
value: string;
title?: string;
type: 'instance' | 'aggregated';
timeSeries?: InspectMetricsSeries;
}
export interface ExpandedViewProps {
options: GraphPopoverOptions | null;
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
step: InspectionStep;
metricInspectionOptions: MetricInspectionOptions;
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
}
export interface TableViewProps {
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
setShowExpandedView: (showExpandedView: boolean) => void;
setExpandedViewOptions: (options: GraphPopoverOptions | null) => void;
metricInspectionOptions: MetricInspectionOptions;
isInspectMetricsRefetching: boolean;
}
export interface TableViewDataItem {
title: JSX.Element;
values: JSX.Element;
key: number;
}

View File

@ -0,0 +1,226 @@
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { themeColors } from 'constants/theme';
import { useGetInspectMetricsDetails } from 'hooks/metricsExplorer/useGetInspectMetricsDetails';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { INITIAL_INSPECT_METRICS_OPTIONS } from './constants';
import {
GraphPopoverData,
InspectionStep,
MetricInspectionAction,
MetricInspectionOptions,
UseInspectMetricsReturnData,
} from './types';
import {
applySpaceAggregation,
applyTimeAggregation,
getAllTimestampsOfMetrics,
} from './utils';
const metricInspectionReducer = (
state: MetricInspectionOptions,
action: MetricInspectionAction,
): MetricInspectionOptions => {
switch (action.type) {
case 'SET_TIME_AGGREGATION_OPTION':
return {
...state,
timeAggregationOption: action.payload,
};
case 'SET_TIME_AGGREGATION_INTERVAL':
return {
...state,
timeAggregationInterval: action.payload,
};
case 'SET_SPACE_AGGREGATION_OPTION':
return {
...state,
spaceAggregationOption: action.payload,
};
case 'SET_SPACE_AGGREGATION_LABELS':
return {
...state,
spaceAggregationLabels: action.payload,
};
case 'SET_FILTERS':
return {
...state,
filters: action.payload,
};
case 'RESET_INSPECTION':
return { ...INITIAL_INSPECT_METRICS_OPTIONS };
default:
return state;
}
};
export function useInspectMetrics(
metricName: string | null,
): UseInspectMetricsReturnData {
// Inspect Metrics API Call and data formatting
const { start, end } = useMemo(() => {
const now = Date.now();
return {
start: now - 30 * 60 * 1000, // 30 minutes ago
end: now, // now
};
}, []);
// Inspect metrics data selection
const [metricInspectionOptions, dispatchMetricInspectionOptions] = useReducer(
metricInspectionReducer,
INITIAL_INSPECT_METRICS_OPTIONS,
);
const {
data: inspectMetricsData,
isLoading: isInspectMetricsLoading,
isError: isInspectMetricsError,
isRefetching: isInspectMetricsRefetching,
} = useGetInspectMetricsDetails(
{
metricName: metricName ?? '',
start,
end,
filters: metricInspectionOptions.filters,
},
{
enabled: !!metricName,
keepPreviousData: true,
},
);
const isDarkMode = useIsDarkMode();
const inspectMetricsTimeSeries = useMemo(() => {
const series = inspectMetricsData?.payload?.data?.series ?? [];
return series.map((series, index) => {
const title = `TS${index + 1}`;
const strokeColor = generateColor(
title,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
return {
...series,
values: [...series.values].sort((a, b) => a.timestamp - b.timestamp),
title,
strokeColor,
};
});
}, [inspectMetricsData, isDarkMode]);
const inspectMetricsStatusCode = useMemo(
() => inspectMetricsData?.statusCode || 200,
[inspectMetricsData],
);
// Evaluate inspection step
const inspectionStep = useMemo(() => {
if (metricInspectionOptions.spaceAggregationOption) {
return InspectionStep.COMPLETED;
}
if (
metricInspectionOptions.timeAggregationOption &&
metricInspectionOptions.timeAggregationInterval
) {
return InspectionStep.SPACE_AGGREGATION;
}
return InspectionStep.TIME_AGGREGATION;
}, [metricInspectionOptions]);
const [spaceAggregatedSeriesMap, setSpaceAggregatedSeriesMap] = useState<
Map<string, InspectMetricsSeries[]>
>(new Map());
const [timeAggregatedSeriesMap, setTimeAggregatedSeriesMap] = useState<
Map<number, GraphPopoverData[]>
>(new Map());
const [aggregatedTimeSeries, setAggregatedTimeSeries] = useState<
InspectMetricsSeries[]
>(inspectMetricsTimeSeries);
useEffect(() => {
setAggregatedTimeSeries(inspectMetricsTimeSeries);
}, [inspectMetricsTimeSeries]);
const formattedInspectMetricsTimeSeries = useMemo(() => {
let timeSeries: InspectMetricsSeries[] = [...inspectMetricsTimeSeries];
// Apply time aggregation once required options are set
if (
inspectionStep >= InspectionStep.SPACE_AGGREGATION &&
metricInspectionOptions.timeAggregationOption &&
metricInspectionOptions.timeAggregationInterval
) {
const {
timeAggregatedSeries,
timeAggregatedSeriesMap,
} = applyTimeAggregation(inspectMetricsTimeSeries, metricInspectionOptions);
timeSeries = timeAggregatedSeries;
setTimeAggregatedSeriesMap(timeAggregatedSeriesMap);
setAggregatedTimeSeries(timeSeries);
}
// Apply space aggregation
if (inspectionStep === InspectionStep.COMPLETED) {
const { aggregatedSeries, spaceAggregatedSeriesMap } = applySpaceAggregation(
timeSeries,
metricInspectionOptions,
);
timeSeries = aggregatedSeries;
setSpaceAggregatedSeriesMap(spaceAggregatedSeriesMap);
setAggregatedTimeSeries(aggregatedSeries);
}
const timestamps = getAllTimestampsOfMetrics(timeSeries);
const timeseriesArray = timeSeries.map((series) => {
const valuesMap = new Map<number, number>();
series.values.forEach(({ timestamp, value }) => {
valuesMap.set(timestamp, parseFloat(value));
});
return timestamps.map((timestamp) => valuesMap.get(timestamp) ?? NaN);
});
const rawData = [timestamps, ...timeseriesArray];
return rawData.map((series) => new Float64Array(series));
}, [inspectMetricsTimeSeries, inspectionStep, metricInspectionOptions]);
const spaceAggregationLabels = useMemo(() => {
const labels = new Set<string>();
inspectMetricsData?.payload?.data.series.forEach((series) => {
Object.keys(series.labels).forEach((label) => {
labels.add(label);
});
});
return Array.from(labels);
}, [inspectMetricsData]);
const reset = useCallback(() => {
dispatchMetricInspectionOptions({
type: 'RESET_INSPECTION',
});
setSpaceAggregatedSeriesMap(new Map());
setTimeAggregatedSeriesMap(new Map());
setAggregatedTimeSeries(inspectMetricsTimeSeries);
}, [dispatchMetricInspectionOptions, inspectMetricsTimeSeries]);
return {
inspectMetricsTimeSeries,
inspectMetricsStatusCode,
isInspectMetricsLoading,
isInspectMetricsError,
formattedInspectMetricsTimeSeries,
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
isInspectMetricsRefetching,
spaceAggregatedSeriesMap,
aggregatedTimeSeries,
timeAggregatedSeriesMap,
reset,
};
}

View File

@ -1,11 +1,827 @@
import { INSPECT_FEATURE_FLAG_KEY } from './constants'; /* eslint-disable no-nested-ternary */
import { Card, Input, Select, Typography } from 'antd';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import classNames from 'classnames';
import { initialQueriesMap } from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { HardHat } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
SPACE_AGGREGATION_OPTIONS,
TIME_AGGREGATION_OPTIONS,
} from './constants';
import {
GraphPopoverData,
GraphPopoverOptions,
InspectionStep,
MetricFiltersProps,
MetricInspectionOptions,
MetricNameSearchProps,
MetricSpaceAggregationProps,
MetricTimeAggregationProps,
SpaceAggregationOptions,
TimeAggregationOptions,
} from './types';
/** /**
* Check if the inspect feature flag is enabled * Check if the inspect feature flag is enabled
* returns true if the feature flag is enabled, false otherwise * returns true if the feature flag is enabled, false otherwise
* Show the inspect button in metrics explorer if the feature flag is enabled * Show the inspect button in metrics explorer if the feature flag is enabled
*/ */
export function isInspectEnabled(): boolean { export function isInspectEnabled(metricType: MetricType | undefined): boolean {
const featureFlag = localStorage.getItem(INSPECT_FEATURE_FLAG_KEY); return metricType === MetricType.GAUGE;
return featureFlag === 'true'; }
export function getAllTimestampsOfMetrics(
inspectMetricsTimeSeries: InspectMetricsSeries[],
): number[] {
return Array.from(
new Set(
inspectMetricsTimeSeries
.flatMap((series) => series.values.map((value) => value.timestamp))
.sort((a, b) => a - b),
),
);
}
export function getDefaultTimeAggregationInterval(
timeSeries: InspectMetricsSeries | undefined,
): number {
if (!timeSeries) {
return 60;
}
const reportingInterval =
timeSeries.values.length > 1
? Math.abs(timeSeries.values[1].timestamp - timeSeries.values[0].timestamp) /
1000
: 0;
return Math.max(60, reportingInterval);
}
export function MetricNameSearch({
metricName,
setMetricName,
}: MetricNameSearchProps): JSX.Element {
const [searchText, setSearchText] = useState(metricName);
const handleSetMetricName = (value: BaseAutocompleteData): void => {
setMetricName(value.key);
};
const handleChange = (value: BaseAutocompleteData): void => {
setSearchText(value.key);
};
return (
<div
data-testid="metric-name-search"
className="inspect-metrics-input-group metric-name-search"
>
<Typography.Text>From</Typography.Text>
<AggregatorFilter
defaultValue={searchText ?? ''}
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
onSelect={handleSetMetricName}
onChange={handleChange}
/>
</div>
);
}
export function MetricFilters({
dispatchMetricInspectionOptions,
searchQuery,
metricName,
metricType,
}: MetricFiltersProps): JSX.Element {
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: searchQuery,
entityVersion: '',
});
const aggregateAttribute = useMemo(
() => ({
key: metricName ?? '',
dataType: DataTypes.String,
type: metricType,
isColumn: true,
isJSON: false,
id: `${metricName}--${DataTypes.String}--${metricType}--true`,
}),
[metricName, metricType],
);
return (
<div
data-testid="metric-filters"
className="inspect-metrics-input-group metric-filters"
>
<Typography.Text>Where</Typography.Text>
<QueryBuilderSearch
query={{
...searchQuery,
aggregateAttribute,
}}
onChange={(value): void => {
handleChangeQueryData('filters', value);
dispatchMetricInspectionOptions({
type: 'SET_FILTERS',
payload: value,
});
}}
suffixIcon={<HardHat size={16} />}
disableNavigationShortcuts
/>
</div>
);
}
export function MetricTimeAggregation({
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
inspectMetricsTimeSeries,
}: MetricTimeAggregationProps): JSX.Element {
return (
<div
data-testid="metric-time-aggregation"
className="metric-time-aggregation"
>
<div
className={classNames('metric-time-aggregation-header', {
'selected-step': inspectionStep === InspectionStep.TIME_AGGREGATION,
})}
>
<Typography.Text>AGGREGATE BY TIME</Typography.Text>
</div>
<div className="metric-time-aggregation-content">
<div className="inspect-metrics-input-group">
<Typography.Text>Align with</Typography.Text>
<Select
value={metricInspectionOptions.timeAggregationOption}
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_OPTION',
payload: value,
});
// set the time aggregation interval to the default value if it is not set
if (!metricInspectionOptions.timeAggregationInterval) {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_INTERVAL',
payload: getDefaultTimeAggregationInterval(
inspectMetricsTimeSeries[0],
),
});
}
}}
style={{ width: 130 }}
placeholder="Select option"
>
{Object.entries(TIME_AGGREGATION_OPTIONS).map(([key, value]) => (
<Select.Option key={key} value={key}>
{value}
</Select.Option>
))}
</Select>
</div>
<div className="inspect-metrics-input-group">
<Typography.Text>aggregated every</Typography.Text>
<Input
type="number"
className="no-arrows-input"
value={metricInspectionOptions.timeAggregationInterval}
placeholder="Select interval..."
suffix="seconds"
onChange={(e): void => {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_INTERVAL',
payload: parseInt(e.target.value, 10),
});
}}
onWheel={(e): void => (e.target as HTMLInputElement).blur()}
/>
</div>
</div>
</div>
);
}
export function MetricSpaceAggregation({
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
}: MetricSpaceAggregationProps): JSX.Element {
return (
<div
data-testid="metric-space-aggregation"
className="metric-space-aggregation"
>
<div
className={classNames('metric-space-aggregation-header', {
'selected-step': inspectionStep === InspectionStep.SPACE_AGGREGATION,
})}
>
<Typography.Text>AGGREGATE BY LABELS</Typography.Text>
</div>
<div className="metric-space-aggregation-content">
<div className="metric-space-aggregation-content-left">
<Select
value={metricInspectionOptions.spaceAggregationOption}
placeholder="Select option"
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_SPACE_AGGREGATION_OPTION',
payload: value,
});
}}
style={{ width: 130 }}
disabled={inspectionStep === InspectionStep.TIME_AGGREGATION}
>
{/* eslint-disable-next-line sonarjs/no-identical-functions */}
{Object.entries(SPACE_AGGREGATION_OPTIONS).map(([key, value]) => (
<Select.Option key={key} value={key}>
{value}
</Select.Option>
))}
</Select>
</div>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="Search for attributes..."
value={metricInspectionOptions.spaceAggregationLabels}
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_SPACE_AGGREGATION_LABELS',
payload: value,
});
}}
disabled={inspectionStep === InspectionStep.TIME_AGGREGATION}
>
{spaceAggregationLabels.map((label) => (
<Select.Option key={label} value={label}>
{label}
</Select.Option>
))}
</Select>
</div>
</div>
);
}
export function applyFilters(
inspectMetricsTimeSeries: InspectMetricsSeries[],
filters: TagFilter,
): InspectMetricsSeries[] {
return inspectMetricsTimeSeries.filter((series) =>
filters.items.every((filter) => {
if ((filter.key?.key || '') in series.labels) {
const value = series.labels[filter.key?.key ?? ''];
switch (filter.op) {
case '=':
return value === filter.value;
case '!=':
return value !== filter.value;
case 'in':
return (filter.value as string[]).includes(value as string);
case 'nin':
return !(filter.value as string[]).includes(value as string);
case 'like':
return value.includes(filter.value as string);
case 'nlike':
return !value.includes(filter.value as string);
case 'contains':
return value.includes(filter.value as string);
case 'ncontains':
return !value.includes(filter.value as string);
default:
return true;
}
}
return false;
}),
);
}
export function applyTimeAggregation(
inspectMetricsTimeSeries: InspectMetricsSeries[],
metricInspectionOptions: MetricInspectionOptions,
): {
timeAggregatedSeries: InspectMetricsSeries[];
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
} {
const {
timeAggregationOption,
timeAggregationInterval,
} = metricInspectionOptions;
if (!timeAggregationInterval) {
return {
timeAggregatedSeries: inspectMetricsTimeSeries,
timeAggregatedSeriesMap: new Map(),
};
}
// Group timestamps into intervals and aggregate values for each series independently
const timeAggregatedSeriesMap: Map<number, GraphPopoverData[]> = new Map();
const timeAggregatedSeries: InspectMetricsSeries[] = inspectMetricsTimeSeries.map(
(series) => {
const groupedTimestamps = new Map<number, number[]>();
series.values.forEach(({ timestamp, value }) => {
const intervalBucket =
Math.floor(timestamp / (timeAggregationInterval * 1000)) *
(timeAggregationInterval * 1000);
if (!groupedTimestamps.has(intervalBucket)) {
groupedTimestamps.set(intervalBucket, []);
}
if (!timeAggregatedSeriesMap.has(intervalBucket)) {
timeAggregatedSeriesMap.set(intervalBucket, []);
}
groupedTimestamps.get(intervalBucket)?.push(parseFloat(value));
timeAggregatedSeriesMap.get(intervalBucket)?.push({
timestamp,
value,
type: 'instance',
title: series.title,
timeSeries: series,
});
});
const aggregatedValues = Array.from(groupedTimestamps.entries()).map(
([intervalStart, values]) => {
let aggregatedValue: number;
switch (timeAggregationOption) {
case TimeAggregationOptions.LATEST:
aggregatedValue = values[values.length - 1];
break;
case TimeAggregationOptions.SUM:
aggregatedValue = values.reduce((sum, val) => sum + val, 0);
break;
case TimeAggregationOptions.AVG:
aggregatedValue =
values.reduce((sum, val) => sum + val, 0) / values.length;
break;
case TimeAggregationOptions.MIN:
aggregatedValue = Math.min(...values);
break;
case TimeAggregationOptions.MAX:
aggregatedValue = Math.max(...values);
break;
case TimeAggregationOptions.COUNT:
aggregatedValue = values.length;
break;
default:
aggregatedValue = values[values.length - 1];
}
return {
timestamp: intervalStart,
value: aggregatedValue.toString(),
};
},
);
return {
...series,
values: aggregatedValues,
};
},
);
return { timeAggregatedSeries, timeAggregatedSeriesMap };
}
export function applySpaceAggregation(
inspectMetricsTimeSeries: InspectMetricsSeries[],
metricInspectionOptions: MetricInspectionOptions,
): {
aggregatedSeries: InspectMetricsSeries[];
spaceAggregatedSeriesMap: Map<string, InspectMetricsSeries[]>;
} {
// Group series by selected space aggregation labels
const groupedSeries = new Map<string, InspectMetricsSeries[]>();
inspectMetricsTimeSeries.forEach((series) => {
// Create composite key from selected labels
const key = metricInspectionOptions.spaceAggregationLabels
.map((label) => `${label}:${series.labels[label]}`)
.join(',');
if (!groupedSeries.has(key)) {
groupedSeries.set(key, []);
}
groupedSeries.get(key)?.push(series);
});
// Aggregate each group based on space aggregation option
const aggregatedSeries: InspectMetricsSeries[] = [];
groupedSeries.forEach((seriesGroup, key) => {
// Get the first series to use as template for labels and timestamps
const templateSeries = seriesGroup[0];
// Create a map of timestamp to array of values across all series in group
const timestampValuesMap = new Map<number, number[]>();
// Collect values for each timestamp across all series
seriesGroup.forEach((series) => {
series.values.forEach(({ timestamp, value }) => {
if (!timestampValuesMap.has(timestamp)) {
timestampValuesMap.set(timestamp, []);
}
timestampValuesMap.get(timestamp)?.push(parseFloat(value));
});
});
// Aggregate values based on selected space aggregation option
const aggregatedValues = Array.from(timestampValuesMap.entries()).map(
([timestamp, values]) => {
let aggregatedValue: number;
switch (metricInspectionOptions.spaceAggregationOption) {
case SpaceAggregationOptions.SUM_BY:
aggregatedValue = values.reduce((sum, val) => sum + val, 0);
break;
case SpaceAggregationOptions.AVG_BY:
aggregatedValue =
values.reduce((sum, val) => sum + val, 0) / values.length;
break;
case SpaceAggregationOptions.MIN_BY:
aggregatedValue = Math.min(...values);
break;
case SpaceAggregationOptions.MAX_BY:
aggregatedValue = Math.max(...values);
break;
default:
// eslint-disable-next-line prefer-destructuring
aggregatedValue = values[0];
}
return {
timestamp,
value: (aggregatedValue || 0).toString(),
};
},
);
// Create aggregated series with original labels
aggregatedSeries.push({
...templateSeries,
values: aggregatedValues.sort((a, b) => a.timestamp - b.timestamp),
title: key.split(',').join(' '),
});
});
return {
aggregatedSeries,
spaceAggregatedSeriesMap: groupedSeries,
};
}
export function getSeriesIndexFromPixel(
e: MouseEvent,
u: uPlot,
formattedInspectMetricsTimeSeries: uPlot.AlignedData,
): number {
const bbox = u.over.getBoundingClientRect(); // plot area only
const left = e.clientX - bbox.left;
const top = e.clientY - bbox.top;
const timestampIndex = u.posToIdx(left);
let seriesIndex = -1;
let closestPixelDiff = Infinity;
for (let i = 1; i < formattedInspectMetricsTimeSeries.length; i++) {
const series = formattedInspectMetricsTimeSeries[i];
const seriesValue = series[timestampIndex];
if (
seriesValue !== undefined &&
seriesValue !== null &&
!Number.isNaN(seriesValue)
) {
const seriesYPx = u.valToPos(seriesValue, 'y');
const pixelDiff = Math.abs(seriesYPx - top);
if (pixelDiff < closestPixelDiff) {
closestPixelDiff = pixelDiff;
seriesIndex = i;
}
}
}
return seriesIndex;
}
export function onGraphClick(
e: MouseEvent,
u: uPlot,
popoverRef: React.RefObject<HTMLDivElement>,
setPopoverOptions: (options: GraphPopoverOptions | null) => void,
inspectMetricsTimeSeries: InspectMetricsSeries[],
showPopover: boolean,
setShowPopover: (showPopover: boolean) => void,
formattedInspectMetricsTimeSeries: uPlot.AlignedData,
): void {
if (popoverRef.current && popoverRef.current.contains(e.target as Node)) {
// Clicked inside the popover, don't close
return;
}
// If popover is already open, close it
if (showPopover) {
setShowPopover(false);
return;
}
// Get which series the user clicked on
// If no series is clicked, return
const seriesIndex = getSeriesIndexFromPixel(
e,
u,
formattedInspectMetricsTimeSeries,
);
if (seriesIndex <= 0) return;
const series = inspectMetricsTimeSeries[seriesIndex - 1];
const { left } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const xVal = u.posToVal(x, 'x'); // Get actual x-axis value
const closestPoint = series?.values.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.timestamp - xVal);
const currDiff = Math.abs(curr.timestamp - xVal);
return prevDiff < currDiff ? prev : curr;
});
setPopoverOptions({
x: e.clientX,
y: e.clientY,
value: parseFloat(closestPoint?.value ?? '0'),
timestamp: closestPoint?.timestamp,
timeSeries: series,
});
setShowPopover(true);
}
export function getRawDataFromTimeSeries(
timeSeries: InspectMetricsSeries,
timestamp: number,
showAll = false,
): GraphPopoverData[] {
if (showAll) {
return timeSeries.values.map((value) => ({
timestamp: value.timestamp,
type: 'instance',
value: value.value,
title: timeSeries.title,
}));
}
const timestampIndex = timeSeries.values.findIndex(
(value) => value.timestamp >= timestamp,
);
const timestamps = [];
if (timestampIndex !== undefined) {
for (
let i = Math.max(0, timestampIndex - 2);
i <= Math.min((timeSeries?.values?.length ?? 0) - 1, timestampIndex + 2);
i++
) {
timestamps.push(timeSeries?.values?.[i]);
}
}
return timestamps.map((timestamp) => ({
timestamp: timestamp.timestamp,
type: 'instance',
value: timestamp.value,
title: timeSeries.title,
}));
}
export function getSpaceAggregatedDataFromTimeSeries(
timeSeries: InspectMetricsSeries,
spaceAggregatedSeriesMap: Map<string, InspectMetricsSeries[]>,
timestamp: number,
showAll = false,
): GraphPopoverData[] {
if (spaceAggregatedSeriesMap.size === 0) {
return [];
}
const appliedLabels =
Array.from(spaceAggregatedSeriesMap.keys())[0]
?.split(',')
.map((label) => label.split(':')[0]) || [];
let matchingSeries: InspectMetricsSeries[] = [];
spaceAggregatedSeriesMap.forEach((series) => {
let isMatching = true;
appliedLabels.forEach((label) => {
if (timeSeries.labels[label] !== series[0].labels[label]) {
isMatching = false;
}
});
if (isMatching) {
matchingSeries = series;
}
});
return matchingSeries
.slice(0, showAll ? matchingSeries.length : 5)
.map((series) => {
const timestampIndex = series.values.findIndex(
(value) => value.timestamp >= timestamp,
);
const value = series.values[timestampIndex]?.value;
return {
timeseries: Object.entries(series.labels)
.map(([key, value]) => `${key}:${value}`)
.join(','),
type: 'aggregated',
value: value ?? '-',
title: series.title,
timeSeries: series,
};
});
}
export const formatTimestampToFullDateTime = (
timestamp: string | number,
returnOnlyTime = false,
): string => {
const date = new Date(Number(timestamp));
const datePart = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const timePart = date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
if (returnOnlyTime) {
return timePart;
}
return `${datePart}${timePart}`;
};
export function getTimeSeriesLabel(
timeSeries: InspectMetricsSeries | null,
textColor: string | undefined,
): JSX.Element {
return (
<>
{Object.entries(timeSeries?.labels ?? {}).map(([key, value]) => (
<span key={key}>
<Typography.Text style={{ color: textColor, fontWeight: 600 }}>
{key}
</Typography.Text>
: {value}{' '}
</span>
))}
</>
);
}
export function HoverPopover({
options,
step,
metricInspectionOptions,
}: {
options: GraphPopoverOptions;
step: InspectionStep;
metricInspectionOptions: MetricInspectionOptions;
}): JSX.Element {
const closestTimestamp = useMemo(() => {
if (!options.timeSeries) {
return options.timestamp;
}
return options.timeSeries?.values.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.timestamp - options.timestamp);
const currDiff = Math.abs(curr.timestamp - options.timestamp);
return prevDiff < currDiff ? prev : curr;
}).timestamp;
}, [options.timeSeries, options.timestamp]);
const closestValue = useMemo(() => {
if (!options.timeSeries) {
return options.value;
}
const index = options.timeSeries.values.findIndex(
(value) => value.timestamp === closestTimestamp,
);
return index !== undefined && index >= 0
? options.timeSeries?.values[index].value
: null;
}, [options.timeSeries, closestTimestamp, options.value]);
const title = useMemo(() => {
if (
step === InspectionStep.COMPLETED &&
metricInspectionOptions.spaceAggregationLabels.length === 0
) {
return undefined;
}
if (step === InspectionStep.COMPLETED && options.timeSeries?.title) {
return options.timeSeries.title;
}
if (!options.timeSeries) {
return undefined;
}
return getTimeSeriesLabel(
options.timeSeries,
options.timeSeries?.strokeColor,
);
}, [step, options.timeSeries, metricInspectionOptions]);
return (
<Card
className="hover-popover-card"
style={{
top: options.y + 10,
left: options.x + 10,
}}
>
<div className="hover-popover-row">
<Typography.Text>
{formatTimestampToFullDateTime(closestTimestamp ?? 0)}
</Typography.Text>
<Typography.Text>{Number(closestValue).toFixed(2)}</Typography.Text>
</div>
{options.timeSeries && (
<Typography.Text
style={{
color: options.timeSeries?.strokeColor,
fontWeight: 200,
}}
>
{title}
</Typography.Text>
)}
</Card>
);
}
export function onGraphHover(
e: MouseEvent,
u: uPlot,
setPopoverOptions: (options: GraphPopoverOptions | null) => void,
inspectMetricsTimeSeries: InspectMetricsSeries[],
formattedInspectMetricsTimeSeries: uPlot.AlignedData,
): void {
const { left, top } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const y = e.clientY - top;
const xVal = u.posToVal(x, 'x'); // Get actual x-axis value
const yVal = u.posToVal(y, 'y'); // Get actual y-axis value value (metric value)
// Get which series the user clicked on
const seriesIndex = getSeriesIndexFromPixel(
e,
u,
formattedInspectMetricsTimeSeries,
);
if (seriesIndex === -1) {
setPopoverOptions({
x: e.clientX,
y: e.clientY,
value: yVal,
timestamp: xVal,
timeSeries: undefined,
});
return;
}
const series = inspectMetricsTimeSeries[seriesIndex - 1];
setPopoverOptions({
x: e.clientX,
y: e.clientY,
value: yVal,
timestamp: xVal,
timeSeries: series,
});
} }

View File

@ -53,7 +53,10 @@ function MetricDetails({
return formatTimestampToReadableDate(metric.lastReceived); return formatTimestampToReadableDate(metric.lastReceived);
}, [metric]); }, [metric]);
const showInspectFeature = useMemo(() => isInspectEnabled(), []); const showInspectFeature = useMemo(
() => isInspectEnabled(metric?.metadata?.metric_type),
[metric],
);
const isMetricDetailsLoading = isLoading || isFetching; const isMetricDetailsLoading = isLoading || isFetching;

View File

@ -120,7 +120,7 @@ function Summary(): JSX.Element {
isFetching: isMetricsFetching, isFetching: isMetricsFetching,
isError: isMetricsError, isError: isMetricsError,
} = useGetMetricsList(metricsListQuery, { } = useGetMetricsList(metricsListQuery, {
enabled: !!metricsListQuery, enabled: !!metricsListQuery && !isInspectModalOpen,
}); });
const { const {
@ -129,7 +129,7 @@ function Summary(): JSX.Element {
isFetching: isTreeMapFetching, isFetching: isTreeMapFetching,
isError: isTreeMapError, isError: isTreeMapError,
} = useGetMetricsTreeMap(metricsTreemapQuery, { } = useGetMetricsTreeMap(metricsTreemapQuery, {
enabled: !!metricsTreemapQuery, enabled: !!metricsTreemapQuery && !isInspectModalOpen,
}); });
const handleFilterChange = useCallback( const handleFilterChange = useCallback(
@ -188,6 +188,10 @@ function Summary(): JSX.Element {
}; };
const closeInspectModal = (): void => { const closeInspectModal = (): void => {
handleChangeQueryData('filters', {
items: [],
op: 'AND',
});
setIsInspectModalOpen(false); setIsInspectModalOpen(false);
setSelectedMetricName(null); setSelectedMetricName(null);
}; };

View File

@ -152,12 +152,17 @@ function ValidateRowValueWrapper({
return <div>{children}</div>; return <div>{children}</div>;
} }
export const formatNumberIntoHumanReadableFormat = (num: number): string => { export const formatNumberIntoHumanReadableFormat = (
num: number,
addPlusSign = true,
): string => {
function format(num: number, divisor: number, suffix: string): string { function format(num: number, divisor: number, suffix: string): string {
const value = num / divisor; const value = num / divisor;
return value % 1 === 0 return value % 1 === 0
? `${value}${suffix}+` ? `${value}${suffix}${addPlusSign ? '+' : ''}`
: `${value.toFixed(1).replace(/\.0$/, '')}${suffix}+`; : `${value.toFixed(1).replace(/\.0$/, '')}${suffix}${
addPlusSign ? '+' : ''
}`;
} }
if (num >= 1_000_000_000) { if (num >= 1_000_000_000) {

View File

@ -2,7 +2,9 @@ import styled from 'styled-components';
interface Props { interface Props {
isDarkMode: boolean; isDarkMode: boolean;
children?: React.ReactNode;
} }
export const StyledLabel = styled.div<Props>` export const StyledLabel = styled.div<Props>`
padding: 0 0.6875rem; padding: 0 0.6875rem;
min-height: 2rem; min-height: 2rem;

View File

@ -5,4 +5,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & { export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & {
query: IBuilderQuery; query: IBuilderQuery;
onChange: (value: BaseAutocompleteData) => void; onChange: (value: BaseAutocompleteData) => void;
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
}; };

View File

@ -35,6 +35,8 @@ export const AggregatorFilter = memo(function AggregatorFilter({
query, query,
disabled, disabled,
onChange, onChange,
defaultValue,
onSelect,
}: AgregatorFilterProps): JSX.Element { }: AgregatorFilterProps): JSX.Element {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]); const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@ -183,6 +185,27 @@ export const AggregatorFilter = memo(function AggregatorFilter({
[getAttributesData, handleChangeCustomValue, onChange], [getAttributesData, handleChangeCustomValue, onChange],
); );
const handleSelect = useCallback(
(_: string, option: ExtendedSelectOption | ExtendedSelectOption[]): void => {
const currentOption = option as ExtendedSelectOption;
const aggregateAttributes = getAttributesData();
if (currentOption.key) {
const attribute = aggregateAttributes.find(
(item) => item.id === currentOption.key,
);
if (attribute && onSelect) {
onSelect(attribute);
}
}
setSearchText('');
},
[getAttributesData, onSelect],
);
const value = removePrefix( const value = removePrefix(
transformStringWithPrefix({ transformStringWithPrefix({
str: query.aggregateAttribute.key, str: query.aggregateAttribute.key,
@ -203,10 +226,11 @@ export const AggregatorFilter = memo(function AggregatorFilter({
onSearch={handleSearchText} onSearch={handleSearchText}
notFoundContent={isFetching ? <Spin size="small" /> : null} notFoundContent={isFetching ? <Spin size="small" /> : null}
options={optionsData} options={optionsData}
value={value} value={defaultValue || value}
onBlur={handleBlur} onBlur={handleBlur}
onChange={handleChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
onSelect={handleSelect}
/> />
); );
}); });

View File

@ -93,6 +93,8 @@ interface QueryBuilderSearchV2Props {
hardcodedAttributeKeys?: BaseAutocompleteData[]; hardcodedAttributeKeys?: BaseAutocompleteData[];
operatorConfigKey?: OperatorConfigKeys; operatorConfigKey?: OperatorConfigKeys;
hideSpanScopeSelector?: boolean; hideSpanScopeSelector?: boolean;
// Determines whether to call onChange when a tag is closed
triggerOnChangeOnClose?: boolean;
} }
export interface Option { export interface Option {
@ -128,6 +130,7 @@ function QueryBuilderSearchV2(
hardcodedAttributeKeys, hardcodedAttributeKeys,
operatorConfigKey, operatorConfigKey,
hideSpanScopeSelector, hideSpanScopeSelector,
triggerOnChangeOnClose,
} = props; } = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@ -902,6 +905,9 @@ function QueryBuilderSearchV2(
onClose(); onClose();
setSearchValue(''); setSearchValue('');
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails))); setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
if (triggerOnChangeOnClose) {
onChange(query.filters);
}
}; };
const tagEditHandler = (value: string): void => { const tagEditHandler = (value: string): void => {
@ -1035,6 +1041,7 @@ QueryBuilderSearchV2.defaultProps = {
hardcodedAttributeKeys: undefined, hardcodedAttributeKeys: undefined,
operatorConfigKey: undefined, operatorConfigKey: undefined,
hideSpanScopeSelector: true, hideSpanScopeSelector: true,
triggerOnChangeOnClose: false,
}; };
export default QueryBuilderSearchV2; export default QueryBuilderSearchV2;

View File

@ -0,0 +1,55 @@
import {
getInspectMetricsDetails,
InspectMetricsRequest,
InspectMetricsResponse,
} from 'api/metricsExplorer/getInspectMetricsDetails';
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 UseGetInspectMetricsDetails = (
requestData: InspectMetricsRequest,
options?: UseQueryOptions<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>;
export const useGetInspectMetricsDetails: UseGetInspectMetricsDetails = (
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_INSPECT_METRICS_DETAILS,
requestData.metricName,
requestData.start,
requestData.end,
requestData.filters,
];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<InspectMetricsResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) =>
getInspectMetricsDetails(requestData, signal, headers),
...options,
queryKey,
});
};