diff --git a/frontend/src/api/metricsExplorer/getInspectMetricsDetails.ts b/frontend/src/api/metricsExplorer/getInspectMetricsDetails.ts new file mode 100644 index 0000000000..cf6eadea74 --- /dev/null +++ b/frontend/src/api/metricsExplorer/getInspectMetricsDetails.ts @@ -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; + labelsArray: Array>; + values: InspectMetricsTimestampValue[]; +} + +interface InspectMetricsTimestampValue { + timestamp: number; + value: string; +} + +export const getInspectMetricsDetails = async ( + request: InspectMetricsRequest, + signal?: AbortSignal, + headers?: Record, +): Promise | 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); + } +}; diff --git a/frontend/src/components/ResizeTable/styles.ts b/frontend/src/components/ResizeTable/styles.ts index 8aad2535b0..6c782922e6 100644 --- a/frontend/src/components/ResizeTable/styles.ts +++ b/frontend/src/components/ResizeTable/styles.ts @@ -1,6 +1,9 @@ +import React from 'react'; import styled from 'styled-components'; -export const SpanStyle = styled.span` +type SpanProps = React.HTMLAttributes; + +export const SpanStyle = styled.span` 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` display: flex; margin: -1rem; padding: 1rem; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index a1671dbbb5..603c172fb5 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -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', diff --git a/frontend/src/container/MetricsExplorer/Inspect/ExpandedView.tsx b/frontend/src/container/MetricsExplorer/Inspect/ExpandedView.tsx new file mode 100644 index 0000000000..29d58f4dc8 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/ExpandedView.tsx @@ -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(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 = 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 ( +
+
+ + +
POINT INSPECTOR
+
+
+ {/* Show only when space aggregation is completed */} + {step === InspectionStep.COMPLETED && ( +
+ + {/* Header */} +
+ + {formatTimestampToFullDateTime(options?.timestamp ?? 0)} + + + {`${absoluteValue} is the ${ + SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW[ + metricInspectionOptions.spaceAggregationOption ?? + SpaceAggregationOptions.SUM_BY + ] + } of`} + +
+ + {/* Table */} +
+
+ + VALUES + +
+ {spaceAggregatedData?.map(({ value, title, timestamp }) => ( + +
+ {value} +
+
+ ))} +
+
+
+ + TIME SERIES + +
+ {spaceAggregatedData?.map(({ title, timeSeries }) => ( + +
{ + setSelectedTimeSeries(timeSeries ?? null); + }} + > + {title} + {selectedTimeSeries?.title === title ? ( + + ) : ( + + )} +
+
+ ))} +
+
+
+
+
+ )} + {/* Show only for space aggregated or raw data */} + {selectedTimeSeries && step !== InspectionStep.SPACE_AGGREGATION && ( +
+ + {/* Header */} +
+ {step !== InspectionStep.COMPLETED && ( + + {formatTimestampToFullDateTime(options?.timestamp ?? 0)} + + )} + + {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} + +
+ + {/* Table */} +
+
+ + RAW VALUES + +
+ {rawData?.map(({ value: rawValue, timestamp, title }) => ( + +
+ {rawValue} +
+
+ ))} +
+
+
+ + TIMESTAMPS + +
+ {rawData?.map(({ timestamp }) => ( + +
+ {formatTimestampToFullDateTime(timestamp ?? '', true)} +
+
+ ))} +
+
+
+
+
+ )} + {/* Show raw values breakdown only for time aggregated data */} + {selectedTimeSeries && step === InspectionStep.SPACE_AGGREGATION && ( +
+ + {/* Header */} +
+ + {formatTimestampToFullDateTime(options?.timestamp ?? 0)} + + + {`${absoluteValue} is the ${ + TIME_AGGREGATION_OPTIONS[ + metricInspectionOptions.timeAggregationOption ?? + TimeAggregationOptions.SUM + ] + } of`} + +
+ + {/* Table */} +
+
+ + RAW VALUES + +
+ {timeAggregatedData?.map(({ value, title, timestamp }) => ( + +
+ {value} +
+
+ ))} +
+
+
+ + TIMESTAMPS + +
+ {timeAggregatedData?.map(({ timestamp }) => ( + +
+ {formatTimestampToFullDateTime(timestamp ?? '', true)} +
+
+ ))} +
+
+
+
+
+ )} + {/* Labels */} + {selectedTimeSeries && ( + <> + {`${selectedTimeSeries?.title} Labels`} + + + )} +
+ ); +} + +export default ExpandedView; diff --git a/frontend/src/container/MetricsExplorer/Inspect/GraphPopover.tsx b/frontend/src/container/MetricsExplorer/Inspect/GraphPopover.tsx new file mode 100644 index 0000000000..68372bf215 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/GraphPopover.tsx @@ -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 ( +
+ +
+ + {formatTimestampToFullDateTime(closestTimestamp)} + + {Number(closestValue).toFixed(2)} +
+
+ +
+
+
+ ); +} + +export default GraphPopover; diff --git a/frontend/src/container/MetricsExplorer/Inspect/GraphView.tsx b/frontend/src/container/MetricsExplorer/Inspect/GraphView.tsx new file mode 100644 index 0000000000..72a9abd5bd --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/GraphView.tsx @@ -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(null); + const dimensions = useResizeObserver(graphRef); + const { maxTime, minTime } = useSelector( + (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(null); + const [viewType, setViewType] = useState<'graph' | 'table'>('graph'); + + const popoverRef = useRef(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 ( +
+
+ + + + +
+ setViewType(checked ? 'graph' : 'table')} + /> + + {viewType === 'graph' ? 'Graph View' : 'Table View'} + +
+
+
+ {viewType === 'graph' && + (isInspectMetricsRefetching ? ( + + ) : ( + + ))} + + {viewType === 'table' && ( + + )} +
+ {showGraphPopover && ( + { + setShowGraphPopover(false); + setShowExpandedView(true); + setExpandedViewOptions(popoverOptions); + }} + /> + )} + {showHoverPopover && !showGraphPopover && hoverPopoverOptions && ( + + )} +
+ ); +} + +export default GraphView; diff --git a/frontend/src/container/MetricsExplorer/Inspect/Inspect.styles.scss b/frontend/src/container/MetricsExplorer/Inspect/Inspect.styles.scss index 938b5a534b..721049f6d6 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/Inspect.styles.scss +++ b/frontend/src/container/MetricsExplorer/Inspect/Inspect.styles.scss @@ -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); + } + } + } + } + } } diff --git a/frontend/src/container/MetricsExplorer/Inspect/Inspect.tsx b/frontend/src/container/MetricsExplorer/Inspect/Inspect.tsx index 443d67ee92..fe3ebf4861 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/Inspect.tsx +++ b/frontend/src/container/MetricsExplorer/Inspect/Inspect.tsx @@ -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(defaultMetricName); + const [ + popoverOptions, + setPopoverOptions, + ] = useState(null); + const [ + expandedViewOptions, + setExpandedViewOptions, + ] = useState(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 ( +
+ +
+ ); + } + + 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 ( +
+ +
+ ); + } + + if (!inspectMetricsTimeSeries.length) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + +
+
+ + {showExpandedView && ( + + )} +
+
+ ); + }, [ + isInspectMetricsLoading, + isInspectMetricsRefetching, + isInspectMetricsError, + inspectMetricsStatusCode, + inspectMetricsTimeSeries, + aggregatedTimeSeries, + formattedInspectMetricsTimeSeries, + resetInspection, + metricName, + selectedMetricUnit, + selectedMetricType, + spaceAggregationSeriesMap, + inspectionStep, + showExpandedView, + popoverOptions, + metricInspectionOptions, + spaceAggregationLabels, + dispatchMetricInspectionOptions, + searchQuery, + expandedViewOptions, + timeAggregatedSeriesMap, + ]); return ( }> @@ -38,8 +259,7 @@ function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element { className="inspect-metrics-modal" destroyOnClose > -
Inspect
-
{metricName}
+ {content}
); diff --git a/frontend/src/container/MetricsExplorer/Inspect/QueryBuilder.tsx b/frontend/src/container/MetricsExplorer/Inspect/QueryBuilder.tsx new file mode 100644 index 0000000000..6f00d8c3a3 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/QueryBuilder.tsx @@ -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 ( +
+
+ +
+ + + + + + +
+ ); +} + +export default QueryBuilder; diff --git a/frontend/src/container/MetricsExplorer/Inspect/Stepper.tsx b/frontend/src/container/MetricsExplorer/Inspect/Stepper.tsx new file mode 100644 index 0000000000..2c97537c39 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/Stepper.tsx @@ -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 ( +
+
+ + πŸ‘‹ Hello, welcome to the Metrics Inspector + + Let’s get you started... +
+
+
InspectionStep.TIME_AGGREGATION, + 'whats-next-checklist-item': + inspectionStep <= InspectionStep.TIME_AGGREGATION, + })} + > +
InspectionStep.TIME_AGGREGATION, + 'whats-next-checklist-item-title': + inspectionStep <= InspectionStep.TIME_AGGREGATION, + })} + > + First, align the data by selecting a{' '} + + Temporal Aggregation{' '} + + +
+
+ +
InspectionStep.SPACE_AGGREGATION, + 'whats-next-checklist-item': + inspectionStep <= InspectionStep.SPACE_AGGREGATION, + })} + > +
InspectionStep.SPACE_AGGREGATION, + 'whats-next-checklist-item-title': + inspectionStep <= InspectionStep.SPACE_AGGREGATION, + })} + > + Add a{' '} + + Spatial Aggregation{' '} + + +
+
+
+ +
+ {inspectionStep === InspectionStep.COMPLETED && ( + <> + + πŸŽ‰ Ta-da! You have completed your metric query tutorial. + + + You can inspect a new metric or reset the query builder. + + + + )} +
+
+ ); +} + +export default Stepper; diff --git a/frontend/src/container/MetricsExplorer/Inspect/TableView.tsx b/frontend/src/container/MetricsExplorer/Inspect/TableView.tsx new file mode 100644 index 0000000000..ad2f02968b --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/TableView.tsx @@ -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 => ( +
{text}
+ ), + })), + { + 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] = ( +
+ {series.labels[label]} +
+ ); + return acc; + }, {} as Record); + + return { + key: index, + ...labelData, + values: ( +
+ + {series.values.map((value) => { + const formattedValue = `(${formatTimestampToFullDateTime( + value.timestamp, + true, + )}, ${value.value})`; + return ( + + openExpandedView(series, value.value, value.timestamp) + } + > + {formattedValue} + + ); + })} + +
+ ), + }; + }), + [inspectMetricsTimeSeries, labelKeys, openExpandedView], + ); + + return ( + + } + scroll={{ x: '100%' }} + loading={isInspectMetricsRefetching} + /> + ); +} + +export default TableView; diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/ExpandedView.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/ExpandedView.test.tsx new file mode 100644 index 0000000000..0831fba2a1 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/ExpandedView.test.tsx @@ -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([ + ['host_id:test-id', [mockTimeSeries]], + ]); + + const mockTimeAggregatedSeriesMap = new Map([ + [ + 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( + , + ); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + expect( + screen.getByText(`${mockTimeSeries.title} Labels`), + ).toBeInTheDocument(); + expect(screen.getByText('host_id')).toBeInTheDocument(); + expect(screen.getByText('test-id')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphPopover.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphPopover.test.tsx new file mode 100644 index 0000000000..29b3b3c581 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphPopover.test.tsx @@ -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( + , + ); + + // Check value is rendered with 2 decimal places + expect(screen.getByText('42.12')).toBeInTheDocument(); + }); + + it('opens the expanded view when button is clicked', () => { + render( + , + ); + + 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( + , + ); + + // Should show the closest value + expect(screen.getByText('43.46')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphView.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphView.test.tsx new file mode 100644 index 0000000000..ab57573501 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphView.test.tsx @@ -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( + + + , + ); + expect(screen.getByRole('switch')).toBeInTheDocument(); + expect(screen.getByText('Graph View')).toBeInTheDocument(); + }); + + it('switches between graph and table view', async () => { + render( + + + , + ); + + 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( + + + , + ); + expect(screen.getByText('test_metric')).toBeInTheDocument(); + expect(screen.getByText('1 time series')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/Inspect.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/Inspect.test.tsx new file mode 100644 index 0000000000..c346420a7e --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/Inspect.test.tsx @@ -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( + + + + + , + ); + + 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( + + + + + , + ); + + 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( + + + + + , + ); + + 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( + + + + + , + ); + + 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( + + + + + , + ); + + expect(screen.getByTestId('inspect-metrics-error')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx new file mode 100644 index 0000000000..35e694d68c --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx @@ -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( + + + + + , + ); + expect(screen.getByText('Query Builder')).toBeInTheDocument(); + }); + + it('renders metric name search component', () => { + render( + + + + + , + ); + expect(screen.getByTestId('metric-name-search')).toBeInTheDocument(); + }); + + it('renders metric filters component', () => { + render( + + + + + , + ); + expect(screen.getByTestId('metric-filters')).toBeInTheDocument(); + }); + + it('renders time aggregation component', () => { + render( + + + + + , + ); + expect(screen.getByTestId('metric-time-aggregation')).toBeInTheDocument(); + }); + + it('renders space aggregation component', () => { + render( + + + + + , + ); + expect(screen.getByTestId('metric-space-aggregation')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/Stepper.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/Stepper.test.tsx new file mode 100644 index 0000000000..d8e1c98798 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/Stepper.test.tsx @@ -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( + , + ); + + expect( + screen.getByText('πŸ‘‹ Hello, welcome to the Metrics Inspector'), + ).toBeInTheDocument(); + }); + + it('shows temporal aggregation step as active when on first step', () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + const resetButton = screen.getByRole('button'); + await userEvent.click(resetButton); + + expect(mockResetInspection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/TableView.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/TableView.test.tsx new file mode 100644 index 0000000000..cc1e2fb5e0 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/TableView.test.tsx @@ -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(); + 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(); + expect(screen.getByText('label1')).toBeInTheDocument(); + expect(screen.getByText('value1')).toBeInTheDocument(); + }); + + it('renders time series values in correct format', () => { + render(); + 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(); + const titles = screen.getByText('value1'); + expect(titles).toHaveStyle({ color: mockTimeSeries[0].strokeColor }); + }); +}); diff --git a/frontend/src/container/MetricsExplorer/Inspect/constants.ts b/frontend/src/container/MetricsExplorer/Inspect/constants.ts index be043c09d6..1bb71fce21 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/constants.ts +++ b/frontend/src/container/MetricsExplorer/Inspect/constants.ts @@ -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.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 & RefAttributes + > +> = { + [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; diff --git a/frontend/src/container/MetricsExplorer/Inspect/types.ts b/frontend/src/container/MetricsExplorer/Inspect/types.ts index 3d5df05aa8..eda878574d 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/types.ts +++ b/frontend/src/container/MetricsExplorer/Inspect/types.ts @@ -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; + aggregatedTimeSeries: InspectMetricsSeries[]; + timeAggregatedSeriesMap: Map; + reset: () => void; +} + +export interface GraphViewProps { + inspectMetricsTimeSeries: InspectMetricsSeries[]; + metricUnit: string | undefined; + metricName: string | null; + metricType?: MetricType | undefined; + formattedInspectMetricsTimeSeries: AlignedData; + resetInspection: () => void; + spaceAggregationSeriesMap: Map; + 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; + options: GraphPopoverOptions | null; + popoverRef: React.RefObject; + 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; + step: InspectionStep; + metricInspectionOptions: MetricInspectionOptions; + timeAggregatedSeriesMap: Map; +} + +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; +} diff --git a/frontend/src/container/MetricsExplorer/Inspect/useInspectMetrics.ts b/frontend/src/container/MetricsExplorer/Inspect/useInspectMetrics.ts new file mode 100644 index 0000000000..20914d164a --- /dev/null +++ b/frontend/src/container/MetricsExplorer/Inspect/useInspectMetrics.ts @@ -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 + >(new Map()); + const [timeAggregatedSeriesMap, setTimeAggregatedSeriesMap] = useState< + Map + >(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(); + + 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(); + 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, + }; +} diff --git a/frontend/src/container/MetricsExplorer/Inspect/utils.tsx b/frontend/src/container/MetricsExplorer/Inspect/utils.tsx index 175a0d7f6b..3405997d7a 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/utils.tsx +++ b/frontend/src/container/MetricsExplorer/Inspect/utils.tsx @@ -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 ( +
+ From + +
+ ); +} + +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 ( +
+ Where + { + handleChangeQueryData('filters', value); + dispatchMetricInspectionOptions({ + type: 'SET_FILTERS', + payload: value, + }); + }} + suffixIcon={} + disableNavigationShortcuts + /> +
+ ); +} + +export function MetricTimeAggregation({ + metricInspectionOptions, + dispatchMetricInspectionOptions, + inspectionStep, + inspectMetricsTimeSeries, +}: MetricTimeAggregationProps): JSX.Element { + return ( +
+
+ AGGREGATE BY TIME +
+
+
+ Align with + +
+
+ aggregated every + { + dispatchMetricInspectionOptions({ + type: 'SET_TIME_AGGREGATION_INTERVAL', + payload: parseInt(e.target.value, 10), + }); + }} + onWheel={(e): void => (e.target as HTMLInputElement).blur()} + /> +
+
+
+ ); +} + +export function MetricSpaceAggregation({ + spaceAggregationLabels, + metricInspectionOptions, + dispatchMetricInspectionOptions, + inspectionStep, +}: MetricSpaceAggregationProps): JSX.Element { + return ( +
+
+ AGGREGATE BY LABELS +
+
+
+ +
+ +
+
+ ); +} + +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; +} { + 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 = new Map(); + + const timeAggregatedSeries: InspectMetricsSeries[] = inspectMetricsTimeSeries.map( + (series) => { + const groupedTimestamps = new Map(); + + 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; +} { + // Group series by selected space aggregation labels + const groupedSeries = new Map(); + + 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(); + + // 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, + 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, + 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]) => ( + + + {key} + + : {value}{' '} + + ))} + + ); +} + +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 ( + +
+ + {formatTimestampToFullDateTime(closestTimestamp ?? 0)} + + {Number(closestValue).toFixed(2)} +
+ {options.timeSeries && ( + + {title} + + )} +
+ ); +} + +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, + }); } diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx index 2781ce5870..c0fc02761a 100644 --- a/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx +++ b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx @@ -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; diff --git a/frontend/src/container/MetricsExplorer/Summary/Summary.tsx b/frontend/src/container/MetricsExplorer/Summary/Summary.tsx index 208248a2d7..e1a493becb 100644 --- a/frontend/src/container/MetricsExplorer/Summary/Summary.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/Summary.tsx @@ -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); }; diff --git a/frontend/src/container/MetricsExplorer/Summary/utils.tsx b/frontend/src/container/MetricsExplorer/Summary/utils.tsx index f558bd535e..37b756cf0d 100644 --- a/frontend/src/container/MetricsExplorer/Summary/utils.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/utils.tsx @@ -152,12 +152,17 @@ function ValidateRowValueWrapper({ return
{children}
; } -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) { diff --git a/frontend/src/container/QueryBuilder/components/FilterLabel/FilterLabel.styled.ts b/frontend/src/container/QueryBuilder/components/FilterLabel/FilterLabel.styled.ts index bc2975a5d3..0ea8b1bc3c 100644 --- a/frontend/src/container/QueryBuilder/components/FilterLabel/FilterLabel.styled.ts +++ b/frontend/src/container/QueryBuilder/components/FilterLabel/FilterLabel.styled.ts @@ -2,7 +2,9 @@ import styled from 'styled-components'; interface Props { isDarkMode: boolean; + children?: React.ReactNode; } + export const StyledLabel = styled.div` padding: 0 0.6875rem; min-height: 2rem; diff --git a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.intefaces.ts b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.intefaces.ts index 35c5f0178a..2d9253cd6c 100644 --- a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.intefaces.ts +++ b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.intefaces.ts @@ -5,4 +5,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; export type AgregatorFilterProps = Pick & { query: IBuilderQuery; onChange: (value: BaseAutocompleteData) => void; + defaultValue?: string; + onSelect?: (value: BaseAutocompleteData) => void; }; diff --git a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx index 60905fa33b..c06e85b037 100644 --- a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx @@ -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([]); @@ -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 ? : null} options={optionsData} - value={value} + value={defaultValue || value} onBlur={handleBlur} onChange={handleChange} disabled={disabled} + onSelect={handleSelect} /> ); }); diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index 8a7d489781..f03f38c534 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -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; diff --git a/frontend/src/hooks/metricsExplorer/useGetInspectMetricsDetails.ts b/frontend/src/hooks/metricsExplorer/useGetInspectMetricsDetails.ts new file mode 100644 index 0000000000..14a1044558 --- /dev/null +++ b/frontend/src/hooks/metricsExplorer/useGetInspectMetricsDetails.ts @@ -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 | ErrorResponse, + Error + >, + headers?: Record, +) => UseQueryResult< + SuccessResponse | 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 | ErrorResponse, + Error + >({ + queryFn: ({ signal }) => + getInspectMetricsDetails(requestData, signal, headers), + ...options, + queryKey, + }); +};