mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-12 10:29:11 +08:00
feat: implement inspect feature for metrics explorer (#7549)
This commit is contained in:
parent
36886135d1
commit
f01d21cbf2
54
frontend/src/api/metricsExplorer/getInspectMetricsDetails.ts
Normal file
54
frontend/src/api/metricsExplorer/getInspectMetricsDetails.ts
Normal 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);
|
||||
}
|
||||
};
|
@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const SpanStyle = styled.span`
|
||||
type SpanProps = React.HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const SpanStyle = styled.span<SpanProps>`
|
||||
position: absolute;
|
||||
right: -0.313rem;
|
||||
bottom: 0;
|
||||
@ -12,7 +15,7 @@ export const SpanStyle = styled.span`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export const DragSpanStyle = styled.span`
|
||||
export const DragSpanStyle = styled.span<SpanProps>`
|
||||
display: flex;
|
||||
margin: -1rem;
|
||||
padding: 1rem;
|
||||
|
@ -51,6 +51,7 @@ export const REACT_QUERY_KEY = {
|
||||
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
|
||||
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
||||
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
||||
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
|
||||
|
||||
// API Monitoring Query Keys
|
||||
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
|
||||
|
350
frontend/src/container/MetricsExplorer/Inspect/ExpandedView.tsx
Normal file
350
frontend/src/container/MetricsExplorer/Inspect/ExpandedView.tsx
Normal 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;
|
@ -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;
|
256
frontend/src/container/MetricsExplorer/Inspect/GraphView.tsx
Normal file
256
frontend/src/container/MetricsExplorer/Inspect/GraphView.tsx
Normal 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;
|
@ -1,4 +1,14 @@
|
||||
.inspect-metrics-modal {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.inspect-metrics-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inspect-metrics-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -13,4 +23,567 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,15 +2,236 @@ import './Inspect.styles.scss';
|
||||
|
||||
import * as Sentry from '@sentry/react';
|
||||
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 { Compass } from 'lucide-react';
|
||||
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 [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 (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
@ -38,8 +259,7 @@ function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element {
|
||||
className="inspect-metrics-modal"
|
||||
destroyOnClose
|
||||
>
|
||||
<div>Inspect</div>
|
||||
<div>{metricName}</div>
|
||||
{content}
|
||||
</Drawer>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
@ -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;
|
92
frontend/src/container/MetricsExplorer/Inspect/Stepper.tsx
Normal file
92
frontend/src/container/MetricsExplorer/Inspect/Stepper.tsx
Normal 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>Let’s 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;
|
136
frontend/src/container/MetricsExplorer/Inspect/TableView.tsx
Normal file
136
frontend/src/container/MetricsExplorer/Inspect/TableView.tsx
Normal 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;
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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 });
|
||||
});
|
||||
});
|
@ -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 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;
|
||||
|
@ -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 = {
|
||||
metricName: string | null;
|
||||
isOpen: boolean;
|
||||
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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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
|
||||
* returns true if the feature flag is enabled, false otherwise
|
||||
* Show the inspect button in metrics explorer if the feature flag is enabled
|
||||
*/
|
||||
export function isInspectEnabled(): boolean {
|
||||
const featureFlag = localStorage.getItem(INSPECT_FEATURE_FLAG_KEY);
|
||||
return featureFlag === 'true';
|
||||
export function isInspectEnabled(metricType: MetricType | undefined): boolean {
|
||||
return metricType === MetricType.GAUGE;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
@ -53,7 +53,10 @@ function MetricDetails({
|
||||
return formatTimestampToReadableDate(metric.lastReceived);
|
||||
}, [metric]);
|
||||
|
||||
const showInspectFeature = useMemo(() => isInspectEnabled(), []);
|
||||
const showInspectFeature = useMemo(
|
||||
() => isInspectEnabled(metric?.metadata?.metric_type),
|
||||
[metric],
|
||||
);
|
||||
|
||||
const isMetricDetailsLoading = isLoading || isFetching;
|
||||
|
||||
|
@ -120,7 +120,7 @@ function Summary(): JSX.Element {
|
||||
isFetching: isMetricsFetching,
|
||||
isError: isMetricsError,
|
||||
} = useGetMetricsList(metricsListQuery, {
|
||||
enabled: !!metricsListQuery,
|
||||
enabled: !!metricsListQuery && !isInspectModalOpen,
|
||||
});
|
||||
|
||||
const {
|
||||
@ -129,7 +129,7 @@ function Summary(): JSX.Element {
|
||||
isFetching: isTreeMapFetching,
|
||||
isError: isTreeMapError,
|
||||
} = useGetMetricsTreeMap(metricsTreemapQuery, {
|
||||
enabled: !!metricsTreemapQuery,
|
||||
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
|
||||
});
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
@ -188,6 +188,10 @@ function Summary(): JSX.Element {
|
||||
};
|
||||
|
||||
const closeInspectModal = (): void => {
|
||||
handleChangeQueryData('filters', {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
});
|
||||
setIsInspectModalOpen(false);
|
||||
setSelectedMetricName(null);
|
||||
};
|
||||
|
@ -152,12 +152,17 @@ function ValidateRowValueWrapper({
|
||||
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 {
|
||||
const value = num / divisor;
|
||||
return value % 1 === 0
|
||||
? `${value}${suffix}+`
|
||||
: `${value.toFixed(1).replace(/\.0$/, '')}${suffix}+`;
|
||||
? `${value}${suffix}${addPlusSign ? '+' : ''}`
|
||||
: `${value.toFixed(1).replace(/\.0$/, '')}${suffix}${
|
||||
addPlusSign ? '+' : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
if (num >= 1_000_000_000) {
|
||||
|
@ -2,7 +2,9 @@ import styled from 'styled-components';
|
||||
|
||||
interface Props {
|
||||
isDarkMode: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StyledLabel = styled.div<Props>`
|
||||
padding: 0 0.6875rem;
|
||||
min-height: 2rem;
|
||||
|
@ -5,4 +5,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: BaseAutocompleteData) => void;
|
||||
defaultValue?: string;
|
||||
onSelect?: (value: BaseAutocompleteData) => void;
|
||||
};
|
||||
|
@ -35,6 +35,8 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
query,
|
||||
disabled,
|
||||
onChange,
|
||||
defaultValue,
|
||||
onSelect,
|
||||
}: AgregatorFilterProps): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
|
||||
@ -183,6 +185,27 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
[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(
|
||||
transformStringWithPrefix({
|
||||
str: query.aggregateAttribute.key,
|
||||
@ -203,10 +226,11 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
onSearch={handleSearchText}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
options={optionsData}
|
||||
value={value}
|
||||
value={defaultValue || value}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -93,6 +93,8 @@ interface QueryBuilderSearchV2Props {
|
||||
hardcodedAttributeKeys?: BaseAutocompleteData[];
|
||||
operatorConfigKey?: OperatorConfigKeys;
|
||||
hideSpanScopeSelector?: boolean;
|
||||
// Determines whether to call onChange when a tag is closed
|
||||
triggerOnChangeOnClose?: boolean;
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
@ -128,6 +130,7 @@ function QueryBuilderSearchV2(
|
||||
hardcodedAttributeKeys,
|
||||
operatorConfigKey,
|
||||
hideSpanScopeSelector,
|
||||
triggerOnChangeOnClose,
|
||||
} = props;
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
@ -902,6 +905,9 @@ function QueryBuilderSearchV2(
|
||||
onClose();
|
||||
setSearchValue('');
|
||||
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
|
||||
if (triggerOnChangeOnClose) {
|
||||
onChange(query.filters);
|
||||
}
|
||||
};
|
||||
|
||||
const tagEditHandler = (value: string): void => {
|
||||
@ -1035,6 +1041,7 @@ QueryBuilderSearchV2.defaultProps = {
|
||||
hardcodedAttributeKeys: undefined,
|
||||
operatorConfigKey: undefined,
|
||||
hideSpanScopeSelector: true,
|
||||
triggerOnChangeOnClose: false,
|
||||
};
|
||||
|
||||
export default QueryBuilderSearchV2;
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user