mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-03 12:32:02 +00:00
Compare commits
7 Commits
testing-fe
...
fix/timepi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8008cb909 | ||
|
|
df7dad81b5 | ||
|
|
4f0e245e3d | ||
|
|
a5549f6d5b | ||
|
|
1d967fadac | ||
|
|
5af6ed6148 | ||
|
|
d094e9cb45 |
@@ -2,7 +2,7 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
// Handles errors from generated API hooks (which use RenderErrorResponseDTO)
|
||||
// @deprecated Use convertToApiError instead
|
||||
export function ErrorResponseHandlerForGeneratedAPIs(
|
||||
error: AxiosError<RenderErrorResponseDTO>,
|
||||
): never {
|
||||
@@ -46,3 +46,34 @@ export function ErrorResponseHandlerForGeneratedAPIs(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// convertToApiError converts an AxiosError from generated API
|
||||
// hooks into an APIError.
|
||||
export function convertToApiError(
|
||||
error: AxiosError<RenderErrorResponseDTO> | null,
|
||||
): APIError | undefined {
|
||||
if (!error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const response = error.response;
|
||||
const errorData = response?.data?.error;
|
||||
|
||||
return new APIError({
|
||||
httpStatusCode: response?.status || error.status || 500,
|
||||
error: {
|
||||
code:
|
||||
errorData?.code ||
|
||||
String(response?.status || error.code || 'unknown_error'),
|
||||
message:
|
||||
errorData?.message ||
|
||||
response?.statusText ||
|
||||
error.message ||
|
||||
'Something went wrong',
|
||||
url: errorData?.url ?? '',
|
||||
errors: (errorData?.errors ?? []).map((e) => ({
|
||||
message: e.message ?? '',
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { TreemapViewType } from 'container/MetricsExplorer/Summary/types';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface MetricsTreeMapPayload {
|
||||
filters: TagFilter;
|
||||
limit?: number;
|
||||
treemap?: TreemapViewType;
|
||||
}
|
||||
|
||||
export interface MetricsTreeMapResponse {
|
||||
status: string;
|
||||
data: {
|
||||
[TreemapViewType.TIMESERIES]: TimeseriesData[];
|
||||
[TreemapViewType.SAMPLES]: SamplesData[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimeseriesData {
|
||||
percentage: number;
|
||||
total_value: number;
|
||||
metric_name: string;
|
||||
}
|
||||
|
||||
export interface SamplesData {
|
||||
percentage: number;
|
||||
metric_name: string;
|
||||
}
|
||||
|
||||
export const getMetricsTreeMap = async (
|
||||
props: MetricsTreeMapPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponse<MetricsTreeMapResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.post('/metrics/treemap', props, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
params: props,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
import { Temporality } from './getMetricDetails';
|
||||
import { MetricType } from './getMetricsList';
|
||||
|
||||
export interface UpdateMetricMetadataProps {
|
||||
description: string;
|
||||
metricType: MetricType;
|
||||
temporality?: Temporality;
|
||||
isMonotonic?: boolean;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMetricMetadataResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const updateMetricMetadata = async (
|
||||
metricName: string,
|
||||
props: UpdateMetricMetadataProps,
|
||||
): Promise<SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse> => {
|
||||
const response = await axios.post(`/metrics/${metricName}/metadata`, {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default updateMetricMetadata;
|
||||
@@ -104,6 +104,10 @@ function CustomTimePicker({
|
||||
const location = useLocation();
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const initialInputValueOnOpenRef = useRef<string>('');
|
||||
const hasChangedSinceOpenRef = useRef<boolean>(false);
|
||||
// Tracks if the last pointer down was on the input so we don't close the popover when user clicks the input again
|
||||
const isClickFromInputRef = useRef(false);
|
||||
|
||||
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
|
||||
|
||||
@@ -238,6 +242,21 @@ function CustomTimePicker({
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean): void => {
|
||||
// Don't close when the user clicked the input (trigger); Ant Design treats trigger as "outside" overlay
|
||||
if (!newOpen && isClickFromInputRef.current) {
|
||||
isClickFromInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
isClickFromInputRef.current = false;
|
||||
|
||||
// If the popover is trying to close and the value changed since opening,
|
||||
// treat it as if the user pressed Enter (attempt to apply the value)
|
||||
if (!newOpen && hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
@@ -406,10 +425,18 @@ function CustomTimePicker({
|
||||
const handleOpen = (e?: React.SyntheticEvent): void => {
|
||||
e?.stopPropagation?.();
|
||||
|
||||
// If the popover is already open, avoid resetting the input value
|
||||
// so that any in-progress edits are preserved.
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLiveLogs) {
|
||||
setOpen(true);
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
setInputValue('Live');
|
||||
initialInputValueOnOpenRef.current = 'Live';
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -424,11 +451,21 @@ function CustomTimePicker({
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
|
||||
|
||||
setInputValue(`${startTime} - ${endTime}`);
|
||||
const nextValue = `${startTime} - ${endTime}`;
|
||||
setInputValue(nextValue);
|
||||
initialInputValueOnOpenRef.current = nextValue;
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
// If the value changed since opening, treat this like pressing Enter
|
||||
if (hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setCustomDTPickerVisible?.(false);
|
||||
|
||||
@@ -450,6 +487,9 @@ function CustomTimePicker({
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleInputBlur = (): void => {
|
||||
// Track whether the value was changed since the input was opened for editing
|
||||
hasChangedSinceOpenRef.current =
|
||||
inputValue !== initialInputValueOnOpenRef.current;
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
@@ -552,6 +592,12 @@ function CustomTimePicker({
|
||||
readOnly={!open || showLiveLogs}
|
||||
placeholder={selectedTimePlaceholderValue}
|
||||
value={inputValue}
|
||||
onMouseDown={(e): void => {
|
||||
// Only treat as "click from input" when the actual input element is clicked (not suffix/chevron)
|
||||
if (e.target === inputRef.current?.input) {
|
||||
isClickFromInputRef.current = true;
|
||||
}
|
||||
}}
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onChange={handleInputChange}
|
||||
|
||||
@@ -49,7 +49,6 @@ export const REACT_QUERY_KEY = {
|
||||
|
||||
// Metrics Explorer Query Keys
|
||||
GET_METRICS_LIST: 'GET_METRICS_LIST',
|
||||
GET_METRICS_TREE_MAP: 'GET_METRICS_TREE_MAP',
|
||||
GET_METRICS_LIST_FILTER_KEYS: 'GET_METRICS_LIST_FILTER_KEYS',
|
||||
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
|
||||
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
@@ -8,7 +9,7 @@ interface BaseChartProps {
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone: string;
|
||||
timezone?: Timezone;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
|
||||
@@ -129,12 +129,12 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDestroy={onPlotDestroy}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone.value}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
timezone={timezone}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -5,12 +5,7 @@ import { getInitialStackedBands } from 'container/DashboardContainer/visualizati
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { get } from 'lodash-es';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
@@ -63,7 +58,12 @@ export function prepareBarPanelConfig({
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
id: widget.id,
|
||||
thresholds: widget.thresholds,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
isLogScale: widget.isLogScale,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -98,14 +98,8 @@ export function prepareBarPanelConfig({
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
label: label,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: VisibilityMode.Never,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
syncMode={DashboardCursorSync.Crosshair}
|
||||
timezone={timezone.value}
|
||||
timezone={timezone}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -154,7 +154,12 @@ export function prepareHistogramPanelConfig({
|
||||
isDarkMode: boolean;
|
||||
}): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
id: widget.id,
|
||||
thresholds: widget.thresholds,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
isLogScale: widget.isLogScale,
|
||||
isDarkMode,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
@@ -191,10 +196,8 @@ export function prepareHistogramPanelConfig({
|
||||
builder.addSeries({
|
||||
label: '',
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
pointSize: 5,
|
||||
lineColor: '#3f5ecc',
|
||||
@@ -216,10 +219,8 @@ export function prepareHistogramPanelConfig({
|
||||
builder.addSeries({
|
||||
label: label,
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
|
||||
@@ -118,7 +118,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
}}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone.value}
|
||||
timezone={timezone}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -82,7 +82,12 @@ export const prepareUPlotConfig = ({
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
id: widget.id,
|
||||
thresholds: widget.thresholds,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
isLogScale: widget.isLogScale,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -120,7 +125,6 @@ export const prepareUPlotConfig = ({
|
||||
: VisibilityMode.Never,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
import { STEP_INTERVAL_MULTIPLIER } from 'lib/uPlotV2/constants';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PanelMode } from '../../types';
|
||||
import { buildBaseConfig } from '../baseConfigBuilder';
|
||||
import { BaseConfigBuilderProps, buildBaseConfig } from '../baseConfigBuilder';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
@@ -27,16 +27,25 @@ jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
|
||||
}));
|
||||
|
||||
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
|
||||
({
|
||||
id: 'widget-1',
|
||||
yAxisUnit: 'ms',
|
||||
isLogScale: false,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
thresholds: [],
|
||||
...overrides,
|
||||
} as Widgets);
|
||||
const createBaseConfigBuilderProps = (
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
BaseConfigBuilderProps,
|
||||
'id' | 'yAxisUnit' | 'isLogScale' | 'softMin' | 'softMax' | 'thresholds'
|
||||
>
|
||||
> = {},
|
||||
): Pick<
|
||||
BaseConfigBuilderProps,
|
||||
'id' | 'yAxisUnit' | 'isLogScale' | 'softMin' | 'softMax' | 'thresholds'
|
||||
> => ({
|
||||
id: 'widget-1',
|
||||
yAxisUnit: 'ms',
|
||||
isLogScale: false,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
thresholds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createApiResponse = (
|
||||
overrides: Partial<MetricRangePayloadProps> = {},
|
||||
@@ -47,7 +56,7 @@ const createApiResponse = (
|
||||
} as MetricRangePayloadProps);
|
||||
|
||||
const baseProps = {
|
||||
widget: createWidget(),
|
||||
...createBaseConfigBuilderProps(),
|
||||
apiResponse: createApiResponse(),
|
||||
isDarkMode: true,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
@@ -63,14 +72,14 @@ describe('buildBaseConfig', () => {
|
||||
expect(typeof builder.getLegendItems).toBe('function');
|
||||
});
|
||||
|
||||
it('configures builder with widgetId and DASHBOARD_VIEW preferences', () => {
|
||||
it('configures builder with id and DASHBOARD_VIEW preferences', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
widget: createWidget({ id: 'my-widget' }),
|
||||
...createBaseConfigBuilderProps({ id: 'my-widget' }),
|
||||
});
|
||||
|
||||
expect(builder.getWidgetId()).toBe('my-widget');
|
||||
expect(builder.getId()).toBe('my-widget');
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -127,7 +136,7 @@ describe('buildBaseConfig', () => {
|
||||
it('configures log scale on y axis when widget.isLogScale is true', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
widget: createWidget({ isLogScale: true }),
|
||||
...createBaseConfigBuilderProps({ isLogScale: true }),
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
@@ -171,7 +180,7 @@ describe('buildBaseConfig', () => {
|
||||
it('adds thresholds from widget', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
widget: createWidget({
|
||||
...createBaseConfigBuilderProps({
|
||||
thresholds: [
|
||||
{
|
||||
thresholdValue: 80,
|
||||
@@ -179,7 +188,7 @@ describe('buildBaseConfig', () => {
|
||||
thresholdUnit: 'ms',
|
||||
thresholdLabel: 'High',
|
||||
},
|
||||
] as Widgets['thresholds'],
|
||||
] as ThresholdProps[],
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
import onClickPlugin, {
|
||||
OnClickPluginOpts,
|
||||
} from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
@@ -9,28 +10,32 @@ import {
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
|
||||
export interface BaseConfigBuilderProps {
|
||||
widget: Widgets;
|
||||
id: string;
|
||||
thresholds?: ThresholdProps[];
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
isDarkMode: boolean;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
timezone?: Timezone;
|
||||
panelMode: PanelMode;
|
||||
panelMode?: PanelMode;
|
||||
panelType: PANEL_TYPES;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
stepInterval?: number;
|
||||
isLogScale?: boolean;
|
||||
yAxisUnit?: string;
|
||||
softMin?: number;
|
||||
softMax?: number;
|
||||
}
|
||||
|
||||
export function buildBaseConfig({
|
||||
widget,
|
||||
id,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -38,9 +43,14 @@ export function buildBaseConfig({
|
||||
timezone,
|
||||
panelMode,
|
||||
panelType,
|
||||
thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
stepInterval,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
softMin,
|
||||
softMax,
|
||||
}: BaseConfigBuilderProps): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
@@ -48,28 +58,27 @@ export function buildBaseConfig({
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
onDragSelect,
|
||||
widgetId: widget.id,
|
||||
tzDate,
|
||||
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
|
||||
selectionPreferencesSource: [
|
||||
PanelMode.DASHBOARD_VIEW,
|
||||
PanelMode.STANDALONE_VIEW,
|
||||
].includes(panelMode)
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
selectionPreferencesSource: panelMode
|
||||
? [PanelMode.DASHBOARD_VIEW, PanelMode.STANDALONE_VIEW].includes(panelMode)
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY
|
||||
: SelectionPreferencesSource.IN_MEMORY,
|
||||
stepInterval,
|
||||
});
|
||||
|
||||
const thresholdOptions: ThresholdsDrawHookOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: (widget.thresholds || []).map((threshold) => ({
|
||||
thresholds: (thresholds || []).map((threshold) => ({
|
||||
thresholdValue: threshold.thresholdValue ?? 0,
|
||||
thresholdColor: threshold.thresholdColor,
|
||||
thresholdUnit: threshold.thresholdUnit,
|
||||
thresholdLabel: threshold.thresholdLabel,
|
||||
})),
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
yAxisUnit: yAxisUnit,
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdOptions);
|
||||
@@ -79,8 +88,8 @@ export function buildBaseConfig({
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
@@ -91,11 +100,11 @@ export function buildBaseConfig({
|
||||
time: false,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
softMin: softMin,
|
||||
softMax: softMax,
|
||||
thresholds: thresholdOptions,
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
@@ -114,7 +123,7 @@ export function buildBaseConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale: widget.isLogScale,
|
||||
isLogScale,
|
||||
panelType,
|
||||
});
|
||||
|
||||
@@ -123,8 +132,8 @@ export function buildBaseConfig({
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
isLogScale: widget.isLogScale,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const getRandomColor = (): string => {
|
||||
};
|
||||
|
||||
export const DATASOURCE_VS_ROUTES: Record<DataSource, string> = {
|
||||
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER,
|
||||
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
[DataSource.TRACES]: ROUTES.TRACES_EXPLORER,
|
||||
[DataSource.LOGS]: ROUTES.LOGS_EXPLORER,
|
||||
};
|
||||
|
||||
@@ -190,6 +190,11 @@
|
||||
.ant-table-cell:nth-child(n + 3) {
|
||||
padding-right: 24px;
|
||||
}
|
||||
.status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.memory-usage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -146,7 +146,14 @@ export const getHostsListColumns = (): ColumnType<HostRowData>[] => [
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
title: (
|
||||
<div className="status-header">
|
||||
Status
|
||||
<Tooltip title="Sent system metrics in last 10 mins">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'active',
|
||||
key: 'active',
|
||||
width: 100,
|
||||
|
||||
@@ -12,14 +12,21 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { explorerViewToPanelType } from 'utils/explorerUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
@@ -42,15 +49,20 @@ function Explorer(): JSX.Element {
|
||||
stagedQuery,
|
||||
updateAllQueriesOperators,
|
||||
currentQuery,
|
||||
handleSetConfig,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
|
||||
const metricNames = useMemo(() => {
|
||||
const currentMetricNames: string[] = [];
|
||||
stagedQuery?.builder.queryData.forEach((query) => {
|
||||
if (query.aggregateAttribute?.key) {
|
||||
currentMetricNames.push(query.aggregateAttribute?.key);
|
||||
const metricName =
|
||||
query.aggregateAttribute?.key ||
|
||||
(query.aggregations?.[0] as MetricAggregation | undefined)?.metricName;
|
||||
if (metricName) {
|
||||
currentMetricNames.push(metricName);
|
||||
}
|
||||
});
|
||||
return currentMetricNames;
|
||||
@@ -176,6 +188,16 @@ function Explorer(): JSX.Element {
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
const nextPanelType =
|
||||
explorerViewToPanelType[view] || PANEL_TYPES.TIME_SERIES;
|
||||
handleSetConfig(nextPanelType, DataSource.METRICS);
|
||||
handleExplorerTabChange(nextPanelType, querySearchParameters);
|
||||
},
|
||||
[handleSetConfig, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
@@ -348,6 +370,7 @@ function Explorer(): JSX.Element {
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
<MetricDetails
|
||||
|
||||
@@ -12,6 +12,7 @@ import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as useOptionsMenuHooks from 'container/OptionsMenu';
|
||||
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
|
||||
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import * as useHandleExplorerTabChangeHooks from 'hooks/useHandleExplorerTabChange';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import * as timezoneHooks from 'providers/Timezone';
|
||||
@@ -29,6 +30,8 @@ const queryClient = new QueryClient();
|
||||
const mockUpdateAllQueriesOperators = jest
|
||||
.fn()
|
||||
.mockReturnValue(initialQueriesMap[DataSource.METRICS]);
|
||||
const mockHandleSetConfig = jest.fn();
|
||||
const mockHandleExplorerTabChange = jest.fn();
|
||||
const mockUseQueryBuilderData = {
|
||||
handleRunQuery: jest.fn(),
|
||||
stagedQuery: initialQueriesMap[DataSource.METRICS],
|
||||
@@ -40,7 +43,7 @@ const mockUseQueryBuilderData = {
|
||||
handleSetQueryData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
handleSetQueryItemData: jest.fn(),
|
||||
handleSetConfig: jest.fn(),
|
||||
handleSetConfig: mockHandleSetConfig,
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
removeQueryTypeItemByIndex: jest.fn(),
|
||||
isDefaultQuery: jest.fn(),
|
||||
@@ -135,6 +138,11 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
...mockUseQueryBuilderData,
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(useHandleExplorerTabChangeHooks, 'useHandleExplorerTabChange')
|
||||
.mockReturnValue({
|
||||
handleExplorerTabChange: mockHandleExplorerTabChange,
|
||||
});
|
||||
|
||||
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
|
||||
|
||||
@@ -382,4 +390,109 @@ describe('Explorer', () => {
|
||||
expect(oneChartPerQueryToggle).toBeEnabled();
|
||||
expect(oneChartPerQueryToggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
describe('loading saved views with v5 query format', () => {
|
||||
const EMPTY_STATE_TEXT = 'Select a metric and run a query to see the results';
|
||||
|
||||
it('should show empty state when no metric is selected', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({}),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [],
|
||||
});
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
...mockUseQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
expect(screen.getByText(EMPTY_STATE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show empty state when saved view has v5 aggregations format', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({}),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
});
|
||||
|
||||
// saved view loaded back from v5 format
|
||||
// aggregateAttribute.key is empty (lost in v3/v4 -> v5 -> v3/v4 round trip)
|
||||
// but aggregations[0].metricName has metric name
|
||||
// TODO(srikanthccv): remove this mess
|
||||
const mockQueryData = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: '',
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'http_requests_total',
|
||||
temporality: 'cumulative',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
};
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [mockQueryData],
|
||||
},
|
||||
},
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
expect(screen.queryByText(EMPTY_STATE_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show empty state when query uses v3 aggregateAttribute format', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({}),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
});
|
||||
|
||||
const mockQueryData = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'system_cpu_usage',
|
||||
},
|
||||
};
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [mockQueryData],
|
||||
},
|
||||
},
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
expect(screen.queryByText(EMPTY_STATE_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'api/generated/services/metrics';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { Bell, Grid } from 'lucide-react';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
@@ -18,8 +17,6 @@ import { DashboardsAndAlertsPopoverProps } from './types';
|
||||
function DashboardsAndAlertsPopover({
|
||||
metricName,
|
||||
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
|
||||
const params = useUrlQuery();
|
||||
|
||||
const {
|
||||
data: alertsData,
|
||||
isLoading: isLoadingAlerts,
|
||||
@@ -71,8 +68,10 @@ function DashboardsAndAlertsPopover({
|
||||
<Typography.Link
|
||||
key={alert.alertId}
|
||||
onClick={(): void => {
|
||||
params.set(QueryParams.ruleId, alert.alertId);
|
||||
window.open(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, '_blank');
|
||||
window.open(
|
||||
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${alert.alertId}`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
@@ -82,7 +81,7 @@ function DashboardsAndAlertsPopover({
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}, [alerts, params]);
|
||||
}, [alerts]);
|
||||
|
||||
const dashboardsPopoverContent = useMemo(() => {
|
||||
if (dashboards && dashboards.length > 0) {
|
||||
|
||||
@@ -16,15 +16,6 @@ import {
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', { value: mockWindowOpen });
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
set: mockSetQuery,
|
||||
toString: jest.fn(),
|
||||
};
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => mockUrlQuery),
|
||||
}));
|
||||
|
||||
const useGetMetricAlertsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
@@ -156,12 +147,10 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
// Click on the first alert rule
|
||||
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
|
||||
|
||||
// Should open alert in new tab
|
||||
expect(mockSetQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleId,
|
||||
MOCK_ALERT_1.alertId,
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`/alerts/overview?${QueryParams.ruleId}=${MOCK_ALERT_1.alertId}`,
|
||||
'_blank',
|
||||
);
|
||||
expect(mockWindowOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders unique dashboards even when there are duplicates', async () => {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from 'antd';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
BarChart,
|
||||
BarChart2,
|
||||
BarChartHorizontal,
|
||||
Diff,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { METRIC_TYPE_LABEL_MAP } from './constants';
|
||||
|
||||
// TODO: @amlannandy Delete this component after API migration is complete
|
||||
function MetricTypeRenderer({ type }: { type: MetricType }): JSX.Element {
|
||||
const [icon, color] = useMemo(() => {
|
||||
switch (type) {
|
||||
case MetricType.SUM:
|
||||
return [
|
||||
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
|
||||
Color.BG_ROBIN_500,
|
||||
];
|
||||
case MetricType.GAUGE:
|
||||
return [
|
||||
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
case MetricType.HISTOGRAM:
|
||||
return [
|
||||
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
|
||||
Color.BG_SIENNA_500,
|
||||
];
|
||||
case MetricType.SUMMARY:
|
||||
return [
|
||||
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
|
||||
Color.BG_FOREST_500,
|
||||
];
|
||||
case MetricType.EXPONENTIAL_HISTOGRAM:
|
||||
return [
|
||||
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
|
||||
Color.BG_AQUA_500,
|
||||
];
|
||||
default:
|
||||
return [null, ''];
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const metricTypeRendererStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: `${color}33`,
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
}),
|
||||
[color],
|
||||
);
|
||||
|
||||
const metricTypeRendererTextStyle = useMemo(
|
||||
() => ({
|
||||
color,
|
||||
fontSize: 12,
|
||||
}),
|
||||
[color],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="metric-type-renderer" style={metricTypeRendererStyle}>
|
||||
{icon}
|
||||
<Typography.Text style={metricTypeRendererTextStyle}>
|
||||
{METRIC_TYPE_LABEL_MAP[type]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricTypeRenderer;
|
||||
@@ -47,7 +47,7 @@ function MetricsSearch({
|
||||
}}
|
||||
onRun={handleRunQuery}
|
||||
showFilterSuggestionsWithoutMetric
|
||||
placeholder="Try metric_name CONTAINS 'http.server' to view all HTTP Server metrics being sent"
|
||||
placeholder="Search your metrics. Try service.name='api' to see all API service metrics, or http.client for HTTP client metrics."
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'antd';
|
||||
import { SorterResult } from 'antd/es/table/interface';
|
||||
import { Querybuildertypesv5OrderDirectionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { MetricsListItemRowData, MetricsTableProps } from './types';
|
||||
@@ -18,6 +19,7 @@ import { getMetricsTableColumns } from './utils';
|
||||
function MetricsTable({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
data,
|
||||
pageSize,
|
||||
currentPage,
|
||||
@@ -71,54 +73,54 @@ function MetricsTable({
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Table
|
||||
loading={{
|
||||
spinning: isLoading,
|
||||
indicator: (
|
||||
<Spin
|
||||
data-testid="metrics-table-loading-state"
|
||||
indicator={<LoadingOutlined size={14} spin />}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
dataSource={data}
|
||||
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
|
||||
locale={{
|
||||
emptyText: isLoading ? null : (
|
||||
<div
|
||||
className="no-metrics-message-container"
|
||||
data-testid={
|
||||
isError ? 'metrics-table-error-state' : 'metrics-table-empty-state'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
{isError && error ? (
|
||||
<ErrorInPlace error={error} />
|
||||
) : (
|
||||
<Table
|
||||
loading={{
|
||||
spinning: isLoading,
|
||||
indicator: (
|
||||
<Spin
|
||||
data-testid="metrics-table-loading-state"
|
||||
indicator={<LoadingOutlined size={14} spin />}
|
||||
/>
|
||||
<Typography.Text className="no-metrics-message">
|
||||
{isError
|
||||
? 'Error fetching metrics. If the problem persists, please contact support.'
|
||||
: 'This query had no results. Edit your query and try again!'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
tableLayout="fixed"
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
hideOnSinglePage: false,
|
||||
onChange: onPaginationChange,
|
||||
total: totalCount,
|
||||
}}
|
||||
onRow={(record): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => openMetricDetails(record.key, 'list'),
|
||||
className: 'clickable-row',
|
||||
})}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
dataSource={data}
|
||||
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
|
||||
locale={{
|
||||
emptyText: isLoading ? null : (
|
||||
<div
|
||||
className="no-metrics-message-container"
|
||||
data-testid="metrics-table-empty-state"
|
||||
>
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
<Typography.Text className="no-metrics-message">
|
||||
This query had no results. Edit your query and try again!
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
tableLayout="fixed"
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
hideOnSinglePage: false,
|
||||
onChange: onPaginationChange,
|
||||
total: totalCount,
|
||||
}}
|
||||
onRow={(record): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => openMetricDetails(record.key, 'list'),
|
||||
className: 'clickable-row',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Group } from '@visx/group';
|
||||
import { Treemap } from '@visx/hierarchy';
|
||||
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { HierarchyNode, stratify, treemapBinary } from 'd3-hierarchy';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
function MetricsTreemapInternal({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
data,
|
||||
viewType,
|
||||
openMetricDetails,
|
||||
@@ -91,6 +93,10 @@ function MetricsTreemapInternal({
|
||||
);
|
||||
}
|
||||
|
||||
if (isError && error) {
|
||||
return <ErrorInPlace error={error} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Empty
|
||||
@@ -174,6 +180,7 @@ function MetricsTreemap({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
openMetricDetails,
|
||||
setHeatmapView,
|
||||
}: MetricsTreemapProps): JSX.Element {
|
||||
@@ -202,6 +209,7 @@ function MetricsTreemap({
|
||||
<MetricsTreemapInternal
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
data={data}
|
||||
viewType={viewType}
|
||||
openMetricDetails={openMetricDetails}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
useGetMetricsStats,
|
||||
useGetMetricsTreemap,
|
||||
@@ -63,13 +64,20 @@ function Summary(): JSX.Element {
|
||||
MetricsexplorertypesTreemapModeDTO.samples,
|
||||
);
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const {
|
||||
currentQuery,
|
||||
stagedQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
|
||||
|
||||
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
|
||||
currentQuery,
|
||||
]);
|
||||
const query = useMemo(
|
||||
() =>
|
||||
stagedQuery?.builder?.queryData?.[0] ||
|
||||
initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
|
||||
@@ -86,14 +94,16 @@ function Summary(): JSX.Element {
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const appliedFilterExpression = query?.filter?.expression || '';
|
||||
|
||||
const [
|
||||
currentQueryFilterExpression,
|
||||
setCurrentQueryFilterExpression,
|
||||
] = useState<string>(query?.filter?.expression || '');
|
||||
] = useState<string>(appliedFilterExpression);
|
||||
|
||||
const [appliedFilterExpression, setAppliedFilterExpression] = useState(
|
||||
query?.filter?.expression || '',
|
||||
);
|
||||
useEffect(() => {
|
||||
setCurrentQueryFilterExpression(appliedFilterExpression);
|
||||
}, [appliedFilterExpression]);
|
||||
|
||||
const queryFilterExpression = useMemo(
|
||||
() => ({ expression: appliedFilterExpression }),
|
||||
@@ -150,6 +160,7 @@ function Summary(): JSX.Element {
|
||||
mutate: getMetricsStats,
|
||||
isLoading: isGetMetricsStatsLoading,
|
||||
isError: isGetMetricsStatsError,
|
||||
error: metricsStatsError,
|
||||
} = useGetMetricsStats();
|
||||
|
||||
const {
|
||||
@@ -157,8 +168,19 @@ function Summary(): JSX.Element {
|
||||
mutate: getMetricsTreemap,
|
||||
isLoading: isGetMetricsTreemapLoading,
|
||||
isError: isGetMetricsTreemapError,
|
||||
error: metricsTreemapError,
|
||||
} = useGetMetricsTreemap();
|
||||
|
||||
const metricsStatsApiError = useMemo(
|
||||
() => convertToApiError(metricsStatsError),
|
||||
[metricsStatsError],
|
||||
);
|
||||
|
||||
const metricsTreemapApiError = useMemo(
|
||||
() => convertToApiError(metricsTreemapError),
|
||||
[metricsTreemapError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getMetricsStats({
|
||||
data: metricsListQuery,
|
||||
@@ -192,8 +214,6 @@ function Summary(): JSX.Element {
|
||||
],
|
||||
},
|
||||
});
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
setAppliedFilterExpression(expression);
|
||||
setCurrentPage(1);
|
||||
if (expression) {
|
||||
logEvent(MetricsExplorerEvents.FilterApplied, {
|
||||
@@ -290,10 +310,14 @@ function Summary(): JSX.Element {
|
||||
};
|
||||
|
||||
const isMetricsListDataEmpty =
|
||||
formattedMetricsData.length === 0 && !isGetMetricsStatsLoading;
|
||||
formattedMetricsData.length === 0 &&
|
||||
!isGetMetricsStatsLoading &&
|
||||
!isGetMetricsStatsError;
|
||||
|
||||
const isMetricsTreeMapDataEmpty =
|
||||
!treeMapData?.data[heatmapView]?.length && !isGetMetricsTreemapLoading;
|
||||
!treeMapData?.data[heatmapView]?.length &&
|
||||
!isGetMetricsTreemapLoading &&
|
||||
!isGetMetricsTreemapError;
|
||||
|
||||
const showFullScreenLoading =
|
||||
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
|
||||
@@ -322,6 +346,7 @@ function Summary(): JSX.Element {
|
||||
data={treeMapData?.data}
|
||||
isLoading={isGetMetricsTreemapLoading}
|
||||
isError={isGetMetricsTreemapError}
|
||||
error={metricsTreemapApiError}
|
||||
viewType={heatmapView}
|
||||
openMetricDetails={openMetricDetails}
|
||||
setHeatmapView={handleSetHeatmapView}
|
||||
@@ -329,6 +354,7 @@ function Summary(): JSX.Element {
|
||||
<MetricsTable
|
||||
isLoading={isGetMetricsStatsLoading}
|
||||
isError={isGetMetricsStatsError}
|
||||
error={metricsStatsApiError}
|
||||
data={formattedMetricsData}
|
||||
pageSize={pageSize}
|
||||
currentPage={currentPage}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
import MetricTypeRenderer from '../MetricTypeRenderer';
|
||||
|
||||
jest.mock('lucide-react', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
Diff: (): JSX.Element => <svg data-testid="diff-icon" />,
|
||||
Gauge: (): JSX.Element => <svg data-testid="gauge-icon" />,
|
||||
BarChart2: (): JSX.Element => <svg data-testid="bar-chart-2-icon" />,
|
||||
BarChartHorizontal: (): JSX.Element => (
|
||||
<svg data-testid="bar-chart-horizontal-icon" />
|
||||
),
|
||||
BarChart: (): JSX.Element => <svg data-testid="bar-chart-icon" />,
|
||||
};
|
||||
});
|
||||
|
||||
describe('MetricTypeRenderer', () => {
|
||||
it('should render correct icon and color for each metric type', () => {
|
||||
const types = [
|
||||
{
|
||||
type: MetricType.SUM,
|
||||
color: Color.BG_ROBIN_500,
|
||||
iconTestId: 'diff-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.GAUGE,
|
||||
color: Color.BG_SAKURA_500,
|
||||
iconTestId: 'gauge-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.HISTOGRAM,
|
||||
color: Color.BG_SIENNA_500,
|
||||
iconTestId: 'bar-chart-2-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.SUMMARY,
|
||||
color: Color.BG_FOREST_500,
|
||||
iconTestId: 'bar-chart-horizontal-icon',
|
||||
},
|
||||
{
|
||||
type: MetricType.EXPONENTIAL_HISTOGRAM,
|
||||
color: Color.BG_AQUA_500,
|
||||
iconTestId: 'bar-chart-icon',
|
||||
},
|
||||
];
|
||||
|
||||
types.forEach(({ type, color, iconTestId }) => {
|
||||
const { container } = render(<MetricTypeRenderer type={type} />);
|
||||
const rendererDiv = container.firstChild as HTMLElement;
|
||||
|
||||
expect(rendererDiv).toHaveStyle({
|
||||
backgroundColor: `${color}33`,
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(iconTestId)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { Filter } from 'api/v5/v5';
|
||||
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
|
||||
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import MetricsTable from '../MetricsTable';
|
||||
import { MetricsListItemRowData } from '../types';
|
||||
@@ -119,12 +120,23 @@ describe('MetricsTable', () => {
|
||||
});
|
||||
|
||||
it('shows error state', () => {
|
||||
const mockError = new APIError({
|
||||
httpStatusCode: 400,
|
||||
error: {
|
||||
code: '400',
|
||||
message: 'invalid filter expression',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<MetricsTable
|
||||
isLoading={false}
|
||||
isError
|
||||
error={mockError}
|
||||
data={[]}
|
||||
pageSize={10}
|
||||
currentPage={1}
|
||||
@@ -139,12 +151,8 @@ describe('MetricsTable', () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('metrics-table-error-state')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Error fetching metrics. If the problem persists, please contact support.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('400')).toBeInTheDocument();
|
||||
expect(screen.getByText('invalid filter expression')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no data', () => {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import * as metricsHooks from 'api/generated/services/metrics';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import * as useGetMetricsTreeMapHooks from 'hooks/metricsExplorer/useGetMetricsTreeMap';
|
||||
import store from 'store';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import Summary from '../Summary';
|
||||
import { TreemapViewType } from '../types';
|
||||
|
||||
jest.mock('d3-hierarchy', () => ({
|
||||
stratify: jest.fn().mockReturnValue({
|
||||
@@ -44,58 +40,135 @@ jest.mock('react-router-dom', () => ({
|
||||
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
|
||||
useShareBuilderUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
// so filter expression assertions easy
|
||||
jest.mock('../MetricsSearch', () => {
|
||||
return function MockMetricsSearch(props: {
|
||||
currentQueryFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div data-testid="metrics-search-expression">
|
||||
{props.currentQueryFilterExpression}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const mockMetricName = 'test-metric';
|
||||
jest.spyOn(useGetMetricsListHooks, 'useGetMetricsList').mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
status: 'success',
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
metric_name: mockMetricName,
|
||||
description: 'description for a test metric',
|
||||
type: MetricType.GAUGE,
|
||||
unit: 'count',
|
||||
lastReceived: '1715702400',
|
||||
[TreemapViewType.TIMESERIES]: 100,
|
||||
[TreemapViewType.SAMPLES]: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
jest.spyOn(useGetMetricsTreeMapHooks, 'useGetMetricsTreeMap').mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
status: 'success',
|
||||
data: {
|
||||
[TreemapViewType.TIMESERIES]: [
|
||||
{
|
||||
metric_name: mockMetricName,
|
||||
percentage: 100,
|
||||
total_value: 100,
|
||||
},
|
||||
],
|
||||
[TreemapViewType.SAMPLES]: [
|
||||
{
|
||||
metric_name: mockMetricName,
|
||||
percentage: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
const mockSetSearchParams = jest.fn();
|
||||
const mockGetMetricsStats = jest.fn();
|
||||
const mockGetMetricsTreemap = jest.fn();
|
||||
|
||||
const mockUseQueryBuilderData = {
|
||||
handleRunQuery: jest.fn(),
|
||||
stagedQuery: initialQueriesMap[DataSource.METRICS],
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
currentQuery: initialQueriesMap[DataSource.METRICS],
|
||||
resetQuery: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
isStagedQueryUpdated: jest.fn(),
|
||||
handleSetQueryData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
handleSetQueryItemData: jest.fn(),
|
||||
handleSetConfig: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
removeQueryTypeItemByIndex: jest.fn(),
|
||||
isDefaultQuery: jest.fn(),
|
||||
};
|
||||
|
||||
const useGetMetricsStatsSpy = jest.spyOn(metricsHooks, 'useGetMetricsStats');
|
||||
const useGetMetricsTreemapSpy = jest.spyOn(
|
||||
metricsHooks,
|
||||
'useGetMetricsTreemap',
|
||||
);
|
||||
const useQueryBuilderSpy = jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder');
|
||||
|
||||
describe('Summary', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams(),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
useGetMetricsStatsSpy.mockReturnValue({
|
||||
data: null,
|
||||
mutate: mockGetMetricsStats,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
isIdle: true,
|
||||
isSuccess: false,
|
||||
reset: jest.fn(),
|
||||
status: 'idle',
|
||||
} as any);
|
||||
|
||||
useGetMetricsTreemapSpy.mockReturnValue({
|
||||
data: null,
|
||||
mutate: mockGetMetricsTreemap,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
isIdle: true,
|
||||
isSuccess: false,
|
||||
reset: jest.fn(),
|
||||
status: 'idle',
|
||||
} as any);
|
||||
|
||||
useQueryBuilderSpy.mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
});
|
||||
|
||||
it('does not carry filter expression from a previous page', async () => {
|
||||
const staleFilterExpression = "service.name = 'redis'";
|
||||
|
||||
// prev filter from logs explorer
|
||||
const staleQuery = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
filter: { expression: staleFilterExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// stagedQuery has stale filter (before QueryBuilder resets it)
|
||||
useQueryBuilderSpy.mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: staleQuery,
|
||||
currentQuery: staleQuery,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
const { rerender } = render(<Summary />);
|
||||
|
||||
expect(screen.getByTestId('metrics-search-expression')).toHaveTextContent(
|
||||
staleFilterExpression,
|
||||
);
|
||||
|
||||
// QB route change effect resets stagedQuery to null
|
||||
useQueryBuilderSpy.mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: null,
|
||||
currentQuery: initialQueriesMap[DataSource.METRICS],
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
rerender(<Summary />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('metrics-search-expression'),
|
||||
).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
it('persists inspect modal open state across page refresh', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({
|
||||
@@ -105,13 +178,7 @@ describe('Summary', () => {
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<Summary />
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
render(<Summary />);
|
||||
|
||||
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -120,18 +187,12 @@ describe('Summary', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({
|
||||
isMetricDetailsOpen: 'true',
|
||||
selectedMetricName: mockMetricName,
|
||||
selectedMetricName: 'test-metric',
|
||||
}),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<Summary />
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
render(<Summary />);
|
||||
|
||||
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
MetricsexplorertypesTreemapModeDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
export const METRICS_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -19,15 +18,6 @@ export const TREEMAP_SQUARE_PADDING = 5;
|
||||
|
||||
export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 };
|
||||
|
||||
// TODO: Remove this once API migration is complete
|
||||
export const METRIC_TYPE_LABEL_MAP = {
|
||||
[MetricType.SUM]: 'Sum',
|
||||
[MetricType.GAUGE]: 'Gauge',
|
||||
[MetricType.HISTOGRAM]: 'Histogram',
|
||||
[MetricType.SUMMARY]: 'Summary',
|
||||
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
|
||||
};
|
||||
|
||||
export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
|
||||
[MetrictypesTypeDTO.sum]: 'Sum',
|
||||
[MetrictypesTypeDTO.gauge]: 'Gauge',
|
||||
@@ -36,15 +26,6 @@ export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
|
||||
[MetrictypesTypeDTO.exponentialhistogram]: 'Exp. Histogram',
|
||||
};
|
||||
|
||||
// TODO(@amlannandy): To remove this once API migration is complete
|
||||
export const METRIC_TYPE_VALUES_MAP: Record<MetricType, string> = {
|
||||
[MetricType.SUM]: 'Sum',
|
||||
[MetricType.GAUGE]: 'Gauge',
|
||||
[MetricType.HISTOGRAM]: 'Histogram',
|
||||
[MetricType.SUMMARY]: 'Summary',
|
||||
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
|
||||
};
|
||||
|
||||
export const METRIC_TYPE_VIEW_VALUES_MAP: Record<MetrictypesTypeDTO, string> = {
|
||||
[MetrictypesTypeDTO.sum]: 'Sum',
|
||||
[MetrictypesTypeDTO.gauge]: 'Gauge',
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Filter } from 'api/v5/v5';
|
||||
import APIError from 'types/api/error';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface MetricsTableProps {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: APIError;
|
||||
data: MetricsListItemRowData[];
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
@@ -33,6 +35,7 @@ export interface MetricsTreemapProps {
|
||||
data: MetricsexplorertypesTreemapResponseDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: APIError;
|
||||
viewType: MetricsexplorertypesTreemapModeDTO;
|
||||
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
|
||||
setHeatmapView: (value: MetricsexplorertypesTreemapModeDTO) => void;
|
||||
@@ -41,6 +44,7 @@ export interface MetricsTreemapProps {
|
||||
export interface MetricsTreemapInternalProps {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: APIError;
|
||||
data: MetricsexplorertypesTreemapResponseDTO | undefined;
|
||||
viewType: MetricsexplorertypesTreemapModeDTO;
|
||||
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import {
|
||||
MetricsexplorertypesListMetricDTO,
|
||||
MetrictypesTypeDTO,
|
||||
@@ -221,6 +227,108 @@ describe('MetricNameSelector', () => {
|
||||
|
||||
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves metric search text for signalSource normalization transition (undefined -> empty)', async () => {
|
||||
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
|
||||
|
||||
const query = makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'http_requests_total',
|
||||
type: '',
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'http_requests_total',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<MetricNameSelector
|
||||
query={query}
|
||||
onChange={jest.fn()}
|
||||
signalSource={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MetricNameSelector query={query} onChange={jest.fn()} signalSource="" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall =
|
||||
mockUseListMetrics.mock.calls[mockUseListMetrics.mock.calls.length - 1];
|
||||
expect(lastCall?.[0]).toMatchObject({
|
||||
searchText: 'http_requests_total',
|
||||
limit: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('updates search text when metric name is hydrated after initial mount', async () => {
|
||||
returnMetrics([makeMetric({ metricName: 'signoz_latency.bucket' })]);
|
||||
|
||||
const emptyQuery = makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: '',
|
||||
type: '',
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
metricName: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
});
|
||||
|
||||
const hydratedQuery = makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: '',
|
||||
type: '',
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_latency.bucket',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<MetricNameSelector
|
||||
query={emptyQuery}
|
||||
onChange={jest.fn()}
|
||||
signalSource=""
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MetricNameSelector
|
||||
query={hydratedQuery}
|
||||
onChange={jest.fn()}
|
||||
signalSource=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall =
|
||||
mockUseListMetrics.mock.calls[mockUseListMetrics.mock.calls.length - 1];
|
||||
expect(lastCall?.[0]).toMatchObject({
|
||||
searchText: 'signoz_latency.bucket',
|
||||
limit: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selecting a metric type updates the aggregation options', () => {
|
||||
|
||||
@@ -98,15 +98,30 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(currentMetricName || defaultValue || '');
|
||||
if (currentMetricName) {
|
||||
setSearchText(currentMetricName);
|
||||
}
|
||||
}, [defaultValue, currentMetricName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevSignalSourceRef.current !== signalSource) {
|
||||
const previousSignalSource = prevSignalSourceRef.current;
|
||||
prevSignalSourceRef.current = signalSource;
|
||||
|
||||
const isNormalizationTransition =
|
||||
(previousSignalSource === undefined && signalSource === '') ||
|
||||
(previousSignalSource === '' && signalSource === undefined);
|
||||
|
||||
if (isNormalizationTransition && currentMetricName) {
|
||||
setSearchText(currentMetricName);
|
||||
setInputValue(currentMetricName || defaultValue || '');
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchText('');
|
||||
setInputValue('');
|
||||
}
|
||||
}, [signalSource]);
|
||||
}, [signalSource, currentMetricName, defaultValue]);
|
||||
|
||||
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
|
||||
@@ -152,7 +167,9 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
}, [metrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
|
||||
const metricName =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key;
|
||||
const hasAggregateAttributeType = query.aggregateAttribute?.type;
|
||||
|
||||
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
|
||||
@@ -164,7 +181,13 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
|
||||
}, [
|
||||
metrics,
|
||||
query.aggregations,
|
||||
query.aggregateAttribute?.key,
|
||||
query.aggregateAttribute?.type,
|
||||
onChange,
|
||||
]);
|
||||
|
||||
const resolveMetricFromText = useCallback(
|
||||
(text: string): BaseAutocompleteData => {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getMetricsTreeMap,
|
||||
MetricsTreeMapPayload,
|
||||
MetricsTreeMapResponse,
|
||||
} from 'api/metricsExplorer/getMetricsTreeMap';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
type UseGetMetricsTreeMap = (
|
||||
requestData: MetricsTreeMapPayload,
|
||||
|
||||
options?: UseQueryOptions<
|
||||
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
|
||||
Error
|
||||
>,
|
||||
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<
|
||||
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
export const useGetMetricsTreeMap: UseGetMetricsTreeMap = (
|
||||
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_METRICS_TREE_MAP, requestData];
|
||||
}, [options?.queryKey, requestData]);
|
||||
|
||||
return useQuery<
|
||||
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
|
||||
Error
|
||||
>({
|
||||
queryFn: ({ signal }) => getMetricsTreeMap(requestData, signal, headers),
|
||||
...options,
|
||||
queryKey,
|
||||
});
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import updateMetricMetadata, {
|
||||
UpdateMetricMetadataProps,
|
||||
UpdateMetricMetadataResponse,
|
||||
} from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface UseUpdateMetricMetadataProps {
|
||||
metricName: string;
|
||||
payload: UpdateMetricMetadataProps;
|
||||
}
|
||||
|
||||
export function useUpdateMetricMetadata(): UseMutationResult<
|
||||
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
|
||||
Error,
|
||||
UseUpdateMetricMetadataProps
|
||||
> {
|
||||
return useMutation<
|
||||
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
|
||||
Error,
|
||||
UseUpdateMetricMetadataProps
|
||||
>({
|
||||
mutationFn: ({ metricName, payload }) =>
|
||||
updateMetricMetadata(metricName, payload),
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
|
||||
@@ -22,6 +23,14 @@ export default function Tooltip({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [listHeight, setListHeight] = useState(0);
|
||||
const tooltipContent = content ?? [];
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
|
||||
const resolvedTimezone = useMemo(() => {
|
||||
if (!timezone) {
|
||||
return userTimezone.value;
|
||||
}
|
||||
return timezone.value;
|
||||
}, [timezone, userTimezone]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
@@ -33,10 +42,10 @@ export default function Tooltip({
|
||||
return null;
|
||||
}
|
||||
return dayjs(data[0][cursorIdx] * 1000)
|
||||
.tz(timezone)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
timezone,
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
|
||||
@@ -83,7 +83,7 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
|
||||
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
|
||||
const defaultProps: TooltipTestProps = {
|
||||
uPlotInstance: createUPlotInstance(null),
|
||||
timezone: 'UTC',
|
||||
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
|
||||
content: [],
|
||||
showTooltipHeader: true,
|
||||
// TooltipRenderArgs (not used directly in component but required by type)
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function UPlotChart({
|
||||
|
||||
setPlotContextInitialState({
|
||||
uPlotInstance: plot,
|
||||
widgetId: config.getWidgetId(),
|
||||
id: config.getId(),
|
||||
shouldSaveSelectionPreference: config.getShouldSaveSelectionPreference(),
|
||||
});
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ const createMockConfig = (): UPlotConfigBuilder => {
|
||||
hooks: {},
|
||||
cursor: {},
|
||||
}),
|
||||
getWidgetId: jest.fn().mockReturnValue(undefined),
|
||||
getId: jest.fn().mockReturnValue(undefined),
|
||||
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
|
||||
} as unknown) as UPlotConfigBuilder;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
@@ -61,7 +62,7 @@ export interface TooltipRenderArgs {
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
timezone: string;
|
||||
timezone?: Timezone;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
|
||||
@@ -74,23 +74,21 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private tzDate: ((timestamp: number) => Date) | undefined;
|
||||
|
||||
private widgetId: string | undefined;
|
||||
private id: string;
|
||||
|
||||
private onDragSelect: (startTime: number, endTime: number) => void;
|
||||
|
||||
constructor(args?: ConfigBuilderProps) {
|
||||
super(args ?? {});
|
||||
constructor(args: ConfigBuilderProps) {
|
||||
super(args);
|
||||
const {
|
||||
widgetId,
|
||||
id,
|
||||
onDragSelect,
|
||||
tzDate,
|
||||
selectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
stepInterval,
|
||||
} = args ?? {};
|
||||
if (widgetId) {
|
||||
this.widgetId = widgetId;
|
||||
}
|
||||
this.id = id;
|
||||
|
||||
if (tzDate) {
|
||||
this.tzDate = tzDate;
|
||||
@@ -252,10 +250,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
*/
|
||||
private getStoredVisibility(): SeriesVisibilityItem[] | null {
|
||||
if (
|
||||
this.widgetId &&
|
||||
this.id &&
|
||||
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
|
||||
) {
|
||||
return getStoredSeriesVisibility(this.widgetId);
|
||||
return getStoredSeriesVisibility(this.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -378,10 +376,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget id
|
||||
* Get the id for the builder
|
||||
*/
|
||||
getWidgetId(): string | undefined {
|
||||
return this.widgetId;
|
||||
getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||
@@ -23,6 +22,9 @@ import {
|
||||
* Path builders are static and shared across all instances of UPlotSeriesBuilder
|
||||
*/
|
||||
let builders: PathBuilders | null = null;
|
||||
|
||||
const DEFAULT_LINE_WIDTH = 2;
|
||||
export const POINT_SIZE_FACTOR = 2.5;
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
constructor(props: SeriesProps) {
|
||||
super(props);
|
||||
@@ -53,7 +55,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
|
||||
const lineConfig: Partial<Series> = {
|
||||
stroke: resolvedLineColor,
|
||||
width: lineWidth ?? 2,
|
||||
width: lineWidth ?? DEFAULT_LINE_WIDTH,
|
||||
};
|
||||
|
||||
if (lineStyle === LineStyle.Dashed) {
|
||||
@@ -66,9 +68,9 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
|
||||
if (fillColor) {
|
||||
lineConfig.fill = fillColor;
|
||||
} else if (this.props.panelType === PANEL_TYPES.BAR) {
|
||||
} else if (this.props.drawStyle === DrawStyle.Bar) {
|
||||
lineConfig.fill = resolvedLineColor;
|
||||
} else if (this.props.panelType === PANEL_TYPES.HISTOGRAM) {
|
||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
||||
lineConfig.fill = `${resolvedLineColor}40`;
|
||||
}
|
||||
|
||||
@@ -137,10 +139,19 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
drawStyle,
|
||||
showPoints,
|
||||
} = this.props;
|
||||
|
||||
/**
|
||||
* If pointSize is not provided, use the lineWidth * POINT_SIZE_FACTOR
|
||||
* to determine the point size.
|
||||
* POINT_SIZE_FACTOR is 2, so the point size will be 2x the line width.
|
||||
*/
|
||||
const resolvedPointSize =
|
||||
pointSize ?? (lineWidth ?? DEFAULT_LINE_WIDTH) * POINT_SIZE_FACTOR;
|
||||
|
||||
const pointsConfig: Partial<Series.Points> = {
|
||||
stroke: resolvedLineColor,
|
||||
fill: resolvedLineColor,
|
||||
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
|
||||
size: resolvedPointSize,
|
||||
filter: pointsFilter || undefined,
|
||||
};
|
||||
|
||||
@@ -231,7 +242,7 @@ function getPathBuilder({
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Bar) {
|
||||
if (drawStyle === DrawStyle.Bar || drawStyle === DrawStyle.Histogram) {
|
||||
const pathBuilders = uPlot.paths;
|
||||
return getBarPathBuilder({
|
||||
pathBuilders,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
@@ -43,27 +42,27 @@ describe('UPlotConfigBuilder', () => {
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns correct save selection preference flag from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: 'widget-123',
|
||||
shouldSaveSelectionPreference: true,
|
||||
});
|
||||
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns widgetId from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
|
||||
it('returns id from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
expect(builder.getWidgetId()).toBe('widget-123');
|
||||
expect(builder.getId()).toBe('widget-123');
|
||||
});
|
||||
|
||||
it('sets tzDate from constructor and includes it in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder({ tzDate });
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123', tzDate });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
@@ -72,7 +71,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
it('does not call onDragSelect for click without drag (width === 0)', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123', onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
@@ -85,14 +84,15 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
// Simulate uPlot calling the hook
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
expect(setSelectHook).toBeDefined();
|
||||
setSelectHook?.(uplotInstance);
|
||||
|
||||
expect(onDragSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDragSelect with start and end times in milliseconds for a drag selection', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123', onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
@@ -111,7 +111,8 @@ describe('UPlotConfigBuilder', () => {
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
expect(setSelectHook).toBeDefined();
|
||||
setSelectHook?.(uplotInstance);
|
||||
|
||||
expect(onDragSelect).toHaveBeenCalledTimes(1);
|
||||
// 100 and 110 seconds converted to milliseconds
|
||||
@@ -119,7 +120,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const drawHook = jest.fn();
|
||||
|
||||
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
|
||||
@@ -134,7 +135,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds axes, scales, and series and wires them into the final config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
// Add axis and scale
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
@@ -170,7 +171,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('merges axis when addAxis is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
|
||||
@@ -183,7 +184,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('merges scale when addScale is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
builder.addScale({ scaleKey: 'y', min: 0 });
|
||||
builder.addScale({ scaleKey: 'y', max: 100 });
|
||||
@@ -204,7 +205,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
id: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -231,7 +232,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
id: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -269,7 +270,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
id: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -302,7 +303,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-dup',
|
||||
id: 'widget-dup',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -329,7 +330,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
it('does not attempt to read stored visibility when using in-memory preferences', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
id: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
@@ -344,7 +345,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds thresholds only once per scale key', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
@@ -362,7 +363,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds multiple thresholds when scale key is different', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
@@ -383,7 +384,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('merges cursor configuration with defaults instead of replacing them', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
@@ -398,7 +399,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
describe('getCursorConfig', () => {
|
||||
it('returns default cursor merged with custom cursor when no stepInterval', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
@@ -412,7 +413,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('returns hover prox as DEFAULT_HOVER_PROXIMITY_VALUE when stepInterval is not set', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
const cursorConfig = builder.getCursorConfig();
|
||||
|
||||
@@ -424,15 +425,15 @@ describe('UPlotConfigBuilder', () => {
|
||||
const mockWidth = 100;
|
||||
calculateWidthBasedOnStepIntervalMock.mockReturnValue(mockWidth);
|
||||
|
||||
const builder = new UPlotConfigBuilder({ stepInterval });
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123', stepInterval });
|
||||
const cursorConfig = builder.getCursorConfig();
|
||||
|
||||
expect(typeof cursorConfig.hover?.prox).toBe('function');
|
||||
|
||||
const uPlotInstance = {} as uPlot;
|
||||
const proxResult = (cursorConfig.hover!.prox as (u: uPlot) => number)(
|
||||
uPlotInstance,
|
||||
);
|
||||
const prox = cursorConfig.hover?.prox as ((u: uPlot) => number) | undefined;
|
||||
expect(prox).toBeDefined();
|
||||
const proxResult = prox ? prox(uPlotInstance) : NaN;
|
||||
|
||||
expect(calculateWidthBasedOnStepIntervalMock).toHaveBeenCalledWith({
|
||||
uPlotInstance,
|
||||
@@ -443,7 +444,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds plugins and includes them in config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const plugin: uPlot.Plugin = {
|
||||
opts: (): void => {},
|
||||
hooks: {},
|
||||
@@ -458,7 +459,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
it('sets padding, legend, focus, select, tzDate, bands and includes them in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
|
||||
|
||||
@@ -480,7 +481,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('does not include plugins when none added', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
@@ -488,7 +489,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('does not include bands when empty', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from '../types';
|
||||
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
@@ -19,7 +18,6 @@ const createBaseProps = (
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
isDarkMode: false,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -137,7 +135,6 @@ describe('UPlotSeriesBuilder', () => {
|
||||
const smallPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 4,
|
||||
pointSize: 2,
|
||||
}),
|
||||
);
|
||||
const largePointsBuilder = new UPlotSeriesBuilder(
|
||||
@@ -150,7 +147,7 @@ describe('UPlotSeriesBuilder', () => {
|
||||
const smallConfig = smallPointsBuilder.getConfig();
|
||||
const largeConfig = largePointsBuilder.getConfig();
|
||||
|
||||
expect(smallConfig.points?.size).toBeUndefined();
|
||||
expect(smallConfig.points?.size).toBe(4 * POINT_SIZE_FACTOR); // should be lineWidth * POINT_SIZE_FACTOR, when pointSize is not provided
|
||||
expect(largeConfig.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export enum SelectionPreferencesSource {
|
||||
* Props for configuring the uPlot config builder
|
||||
*/
|
||||
export interface ConfigBuilderProps {
|
||||
widgetId?: string;
|
||||
id: string;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
tzDate?: uPlot.LocalDateFromUnix;
|
||||
selectionPreferencesSource?: SelectionPreferencesSource;
|
||||
@@ -112,6 +112,7 @@ export enum DrawStyle {
|
||||
Line = 'line',
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
Histogram = 'histogram',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -168,7 +169,6 @@ export interface PointsConfig {
|
||||
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
scaleKey: string;
|
||||
label?: string;
|
||||
panelType: PANEL_TYPES;
|
||||
colorMapping: Record<string, string>;
|
||||
drawStyle: DrawStyle;
|
||||
pathBuilder?: Series.PathBuilder;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContain
|
||||
import type uPlot from 'uplot';
|
||||
export interface PlotContextInitialState {
|
||||
uPlotInstance: uPlot | null;
|
||||
widgetId?: string;
|
||||
id?: string;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
export interface IPlotContext {
|
||||
@@ -31,17 +31,17 @@ export const PlotContextProvider = ({
|
||||
}: PropsWithChildren): JSX.Element => {
|
||||
const uPlotInstanceRef = useRef<uPlot | null>(null);
|
||||
const activeSeriesIndex = useRef<number | undefined>(undefined);
|
||||
const widgetIdRef = useRef<string | undefined>(undefined);
|
||||
const idRef = useRef<string | undefined>(undefined);
|
||||
const shouldSavePreferencesRef = useRef<boolean>(false);
|
||||
|
||||
const setPlotContextInitialState = useCallback(
|
||||
({
|
||||
uPlotInstance,
|
||||
widgetId,
|
||||
id,
|
||||
shouldSaveSelectionPreference,
|
||||
}: PlotContextInitialState): void => {
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
widgetIdRef.current = widgetId;
|
||||
idRef.current = id;
|
||||
activeSeriesIndex.current = undefined;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
@@ -50,7 +50,7 @@ export const PlotContextProvider = ({
|
||||
|
||||
const syncSeriesVisibilityToLocalStorage = useCallback((): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot || !widgetIdRef.current) {
|
||||
if (!plot || !idRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const PlotContextProvider = ({
|
||||
}),
|
||||
);
|
||||
|
||||
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
|
||||
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
|
||||
}, []);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback(
|
||||
@@ -84,7 +84,7 @@ export const PlotContextProvider = ({
|
||||
show: isReset || currentSeriesIndex === seriesIndex,
|
||||
});
|
||||
});
|
||||
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export const PlotContextProvider = ({
|
||||
return;
|
||||
}
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,13 +32,13 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
|
||||
interface TestComponentProps {
|
||||
plot?: uPlot;
|
||||
widgetId?: string;
|
||||
id?: string;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
const TestComponent = ({
|
||||
plot,
|
||||
widgetId,
|
||||
id,
|
||||
shouldSaveSelectionPreference,
|
||||
}: TestComponentProps): JSX.Element => {
|
||||
const {
|
||||
@@ -49,17 +49,13 @@ const TestComponent = ({
|
||||
onFocusSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (
|
||||
!plot ||
|
||||
!widgetId ||
|
||||
typeof shouldSaveSelectionPreference !== 'boolean'
|
||||
) {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
|
||||
setPlotContextInitialState({
|
||||
uPlotInstance: plot,
|
||||
widgetId,
|
||||
id,
|
||||
shouldSaveSelectionPreference,
|
||||
});
|
||||
};
|
||||
@@ -148,11 +144,7 @@ describe('PlotContext', () => {
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-123"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
@@ -199,7 +191,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-visibility"
|
||||
id="widget-visibility"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -240,7 +232,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-reset"
|
||||
id="widget-reset"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -290,7 +282,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-toggle"
|
||||
id="widget-toggle"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -316,7 +308,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-missing-series"
|
||||
id="widget-missing-series"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -341,7 +333,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-no-persist"
|
||||
id="widget-no-persist"
|
||||
shouldSaveSelectionPreference={false}
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -379,7 +371,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-focus"
|
||||
id="widget-focus"
|
||||
shouldSaveSelectionPreference={false}
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
|
||||
@@ -40,7 +40,7 @@ class TestConfigBuilder extends UPlotConfigBuilder {
|
||||
type ConfigMock = TestConfigBuilder;
|
||||
|
||||
function createConfigMock(): ConfigMock {
|
||||
return new TestConfigBuilder();
|
||||
return new TestConfigBuilder({ id: 'test-widget' });
|
||||
}
|
||||
|
||||
function getHandler(config: ConfigMock, hookName: string): HookHandler {
|
||||
|
||||
@@ -25,7 +25,7 @@ function resetStore(): void {
|
||||
|
||||
function mockContext(overrides: Partial<VariableFetchContext> = {}): void {
|
||||
getVariableDependencyContextSpy.mockReturnValue({
|
||||
doAllVariablesHaveValuesSelected: false,
|
||||
doAllQueryVariablesHaveValuesSelected: false,
|
||||
variableTypes: {},
|
||||
dynamicVariableOrder: [],
|
||||
dependencyData: null,
|
||||
@@ -175,9 +175,9 @@ describe('variableFetchStore', () => {
|
||||
expect(storeSnapshot.cycleIds.b).toBe(1);
|
||||
});
|
||||
|
||||
it('should set dynamic variables to waiting when not all variables have values', () => {
|
||||
it('should set dynamic variables to waiting when not all query variables have values', () => {
|
||||
mockContext({
|
||||
doAllVariablesHaveValuesSelected: false,
|
||||
doAllQueryVariablesHaveValuesSelected: false,
|
||||
dependencyData: buildDependencyData({ order: [] }),
|
||||
variableTypes: { dyn1: 'DYNAMIC' },
|
||||
dynamicVariableOrder: ['dyn1'],
|
||||
@@ -190,9 +190,9 @@ describe('variableFetchStore', () => {
|
||||
expect(storeSnapshot.states.dyn1).toBe('waiting');
|
||||
});
|
||||
|
||||
it('should set dynamic variables to loading when all variables have values', () => {
|
||||
it('should set dynamic variables to loading when all query variables have values', () => {
|
||||
mockContext({
|
||||
doAllVariablesHaveValuesSelected: true,
|
||||
doAllQueryVariablesHaveValuesSelected: true,
|
||||
dependencyData: buildDependencyData({ order: [] }),
|
||||
variableTypes: { dyn1: 'DYNAMIC' },
|
||||
dynamicVariableOrder: ['dyn1'],
|
||||
@@ -523,5 +523,77 @@ describe('variableFetchStore', () => {
|
||||
|
||||
expect(variableFetchStore.getSnapshot().states.b).toBe('revalidating');
|
||||
});
|
||||
|
||||
it('should enqueue dynamic variables immediately when all query variables are settled', () => {
|
||||
mockContext({
|
||||
dependencyData: buildDependencyData({
|
||||
transitiveDescendants: { customVar: [] },
|
||||
parentDependencyGraph: {},
|
||||
}),
|
||||
variableTypes: { q1: 'QUERY', customVar: 'CUSTOM', dyn1: 'DYNAMIC' },
|
||||
dynamicVariableOrder: ['dyn1'],
|
||||
});
|
||||
|
||||
variableFetchStore.update((d) => {
|
||||
d.states.q1 = 'idle';
|
||||
d.states.customVar = 'idle';
|
||||
d.states.dyn1 = 'idle';
|
||||
});
|
||||
|
||||
enqueueDescendantsOfVariable('customVar');
|
||||
|
||||
const snapshot = variableFetchStore.getSnapshot();
|
||||
expect(snapshot.states.dyn1).toBe('loading');
|
||||
expect(snapshot.cycleIds.dyn1).toBe(1);
|
||||
});
|
||||
|
||||
it('should set dynamic variables to waiting when query variables are not yet settled', () => {
|
||||
// a is a query variable still loading; changing customVar should queue dyn1 as waiting
|
||||
mockContext({
|
||||
dependencyData: buildDependencyData({
|
||||
transitiveDescendants: { customVar: [] },
|
||||
parentDependencyGraph: {},
|
||||
}),
|
||||
variableTypes: { a: 'QUERY', customVar: 'CUSTOM', dyn1: 'DYNAMIC' },
|
||||
dynamicVariableOrder: ['dyn1'],
|
||||
});
|
||||
|
||||
variableFetchStore.update((d) => {
|
||||
d.states.a = 'loading';
|
||||
d.states.customVar = 'idle';
|
||||
d.states.dyn1 = 'idle';
|
||||
});
|
||||
|
||||
enqueueDescendantsOfVariable('customVar');
|
||||
|
||||
expect(variableFetchStore.getSnapshot().states.dyn1).toBe('waiting');
|
||||
});
|
||||
|
||||
it('should set dynamic variables to waiting when a query descendant is now loading', () => {
|
||||
// a -> b (QUERY), dyn1 (DYNAMIC). When a changes, b starts loading,
|
||||
// so dyn1 should wait until b settles.
|
||||
mockContext({
|
||||
dependencyData: buildDependencyData({
|
||||
transitiveDescendants: { a: ['b'] },
|
||||
parentDependencyGraph: { b: ['a'] },
|
||||
}),
|
||||
variableTypes: { a: 'QUERY', b: 'QUERY', dyn1: 'DYNAMIC' },
|
||||
dynamicVariableOrder: ['dyn1'],
|
||||
});
|
||||
|
||||
variableFetchStore.update((d) => {
|
||||
d.states.a = 'idle';
|
||||
d.states.b = 'idle';
|
||||
d.states.dyn1 = 'idle';
|
||||
});
|
||||
|
||||
enqueueDescendantsOfVariable('a');
|
||||
|
||||
const snapshot = variableFetchStore.getSnapshot();
|
||||
// b's parent (a) is idle → b starts loading
|
||||
expect(snapshot.states.b).toBe('loading');
|
||||
// dyn1 must wait because b is now loading (not settled)
|
||||
expect(snapshot.states.dyn1).toBe('waiting');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('dashboardVariablesStore', () => {
|
||||
expect(dependencyData).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should report doAllVariablesHaveValuesSelected as true when all variables have selectedValue', () => {
|
||||
it('should report doAllQueryVariablesHaveValuesSelected as true when all query variables have values', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
@@ -146,18 +146,46 @@ describe('dashboardVariablesStore', () => {
|
||||
}),
|
||||
region: createVariable({
|
||||
name: 'region',
|
||||
type: 'CUSTOM',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'us-east',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
const {
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
} = getVariableDependencyContext();
|
||||
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should report doAllVariablesHaveValuesSelected as false when some variables lack selectedValue', () => {
|
||||
it('should report doAllQueryVariablesHaveValuesSelected as false when a query variable lacks a selectedValue', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 0,
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
region: createVariable({
|
||||
name: 'region',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
} = getVariableDependencyContext();
|
||||
expect(doAllQueryVariablesHaveValuesSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore non-QUERY variables when computing doAllQueryVariablesHaveValuesSelected', () => {
|
||||
// env (QUERY) has a value; region (CUSTOM) and dyn1 (DYNAMIC) do not — they are ignored
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
@@ -173,62 +201,22 @@ describe('dashboardVariablesStore', () => {
|
||||
order: 1,
|
||||
selectedValue: undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('should treat DYNAMIC variable with allSelected=true and selectedValue=null as having a value', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
dyn1: createVariable({
|
||||
name: 'dyn1',
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: null as any,
|
||||
allSelected: true,
|
||||
}),
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'prod',
|
||||
order: 2,
|
||||
selectedValue: '',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
const {
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
} = getVariableDependencyContext();
|
||||
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat DYNAMIC variable with allSelected=true and selectedValue=undefined as having a value', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
dyn1: createVariable({
|
||||
name: 'dyn1',
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: undefined,
|
||||
allSelected: true,
|
||||
}),
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat DYNAMIC variable with allSelected=true and empty string selectedValue as having a value', () => {
|
||||
it('should return true for doAllQueryVariablesHaveValuesSelected when there are no query variables', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
@@ -237,61 +225,72 @@ describe('dashboardVariablesStore', () => {
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: '',
|
||||
allSelected: true,
|
||||
}),
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
const {
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
} = getVariableDependencyContext();
|
||||
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat DYNAMIC variable with allSelected=true and empty array selectedValue as having a value', () => {
|
||||
// Any non-nil, non-empty-array selectedValue is treated as selected
|
||||
it.each([
|
||||
{ label: 'numeric 0', selectedValue: 0 as number },
|
||||
{ label: 'boolean false', selectedValue: false as boolean },
|
||||
// ideally not possible but till we have concrete schema, we should not block dynamic variables
|
||||
{ label: 'empty string', selectedValue: '' },
|
||||
{
|
||||
label: 'non-empty array',
|
||||
selectedValue: ['a', 'b'] as (string | number | boolean)[],
|
||||
},
|
||||
])('should return true when selectedValue is $label', ({ selectedValue }) => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
dyn1: createVariable({
|
||||
name: 'dyn1',
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: [] as any,
|
||||
allSelected: true,
|
||||
}),
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should report false when a DYNAMIC variable has empty selectedValue and allSelected is not true', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
dyn1: createVariable({
|
||||
name: 'dyn1',
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: '',
|
||||
allSelected: false,
|
||||
selectedValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(false);
|
||||
const {
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
} = getVariableDependencyContext();
|
||||
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
// null/undefined (tested above) and empty array are treated as not selected
|
||||
it.each([
|
||||
{
|
||||
label: 'null',
|
||||
selectedValue: null as IDashboardVariable['selectedValue'],
|
||||
},
|
||||
{ label: 'empty array', selectedValue: [] as (string | number | boolean)[] },
|
||||
])(
|
||||
'should return false when selectedValue is $label',
|
||||
({ selectedValue }) => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 0,
|
||||
selectedValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
} = getVariableDependencyContext();
|
||||
expect(doAllQueryVariablesHaveValuesSelected).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
import createStore from '../store';
|
||||
import { VariableFetchContext } from '../variableFetchStore';
|
||||
@@ -68,28 +68,28 @@ export function updateDashboardVariablesStore({
|
||||
*/
|
||||
export function getVariableDependencyContext(): VariableFetchContext {
|
||||
const state = dashboardVariablesStore.getSnapshot();
|
||||
// Dynamic variables should only wait on query variables having values,
|
||||
// not on CUSTOM, TEXTBOX, or other types.
|
||||
const doAllQueryVariablesHaveValuesSelected = Object.values(
|
||||
state.variables,
|
||||
).every((variable) => {
|
||||
if (variable.type !== 'QUERY') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If every variable already has a selectedValue (e.g. persisted from
|
||||
// localStorage/URL), dynamic variables can start in parallel.
|
||||
// Otherwise they wait for query vars to settle first.
|
||||
const doAllVariablesHaveValuesSelected = Object.values(state.variables).every(
|
||||
(variable) => {
|
||||
if (
|
||||
variable.type === 'DYNAMIC' &&
|
||||
(variable.selectedValue === null || isEmpty(variable.selectedValue)) &&
|
||||
variable.allSelected === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (isNil(variable.selectedValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isUndefined(variable.selectedValue) && !isEmpty(variable.selectedValue)
|
||||
);
|
||||
},
|
||||
);
|
||||
if (Array.isArray(variable.selectedValue)) {
|
||||
return variable.selectedValue.length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
doAllVariablesHaveValuesSelected,
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
variableTypes: state.variableTypes,
|
||||
dynamicVariableOrder: state.dynamicVariableOrder,
|
||||
dependencyData: state.dependencyData,
|
||||
|
||||
@@ -36,7 +36,7 @@ export type VariableFetchContext = Pick<
|
||||
IDashboardVariablesStoreState,
|
||||
'variableTypes' | 'dynamicVariableOrder' | 'dependencyData'
|
||||
> & {
|
||||
doAllVariablesHaveValuesSelected: boolean;
|
||||
doAllQueryVariablesHaveValuesSelected: boolean;
|
||||
};
|
||||
|
||||
const initialState: IVariableFetchStoreState = {
|
||||
@@ -88,7 +88,7 @@ export function initializeVariableFetchStore(variableNames: string[]): void {
|
||||
*/
|
||||
export function enqueueFetchOfAllVariables(): void {
|
||||
const {
|
||||
doAllVariablesHaveValuesSelected,
|
||||
doAllQueryVariablesHaveValuesSelected,
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
dynamicVariableOrder,
|
||||
@@ -116,7 +116,7 @@ export function enqueueFetchOfAllVariables(): void {
|
||||
// otherwise wait for query variables to settle first
|
||||
dynamicVariableOrder.forEach((name) => {
|
||||
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
|
||||
draft.states[name] = doAllVariablesHaveValuesSelected
|
||||
draft.states[name] = doAllQueryVariablesHaveValuesSelected
|
||||
? resolveFetchState(draft, name)
|
||||
: 'waiting';
|
||||
});
|
||||
@@ -208,7 +208,11 @@ export function onVariableFetchFailure(name: string): void {
|
||||
* ensures parents are set before children within a single update).
|
||||
*/
|
||||
export function enqueueDescendantsOfVariable(name: string): void {
|
||||
const { dependencyData, variableTypes } = getVariableDependencyContext();
|
||||
const {
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
dynamicVariableOrder,
|
||||
} = getVariableDependencyContext();
|
||||
if (!dependencyData) {
|
||||
return;
|
||||
}
|
||||
@@ -230,5 +234,18 @@ export function enqueueDescendantsOfVariable(name: string): void {
|
||||
? resolveFetchState(draft, desc)
|
||||
: 'waiting';
|
||||
});
|
||||
|
||||
// Dynamic variables implicitly depend on all query variable values.
|
||||
// If all query variables are currently settled, start them immediately;
|
||||
// otherwise they wait until query vars finish (unlocked via onVariableFetchComplete).
|
||||
dynamicVariableOrder.forEach((dynName) => {
|
||||
draft.cycleIds[dynName] = (draft.cycleIds[dynName] || 0) + 1;
|
||||
draft.states[dynName] = areAllQueryVariablesSettled(
|
||||
draft.states,
|
||||
variableTypes,
|
||||
)
|
||||
? resolveFetchState(draft, dynName)
|
||||
: 'waiting';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3541,6 +3541,45 @@ func (r *ClickHouseReader) GetCountOfThings(ctx context.Context, query string) (
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetActiveHostsFromMetricMetadata(ctx context.Context, metricNames []string, hostNameAttr string, sinceUnixMilli int64) (map[string]bool, error) {
|
||||
activeHosts := map[string]bool{}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT DISTINCT attr_string_value
|
||||
FROM %s.%s
|
||||
WHERE metric_name IN @metricNames
|
||||
AND attr_name = @attrName
|
||||
AND last_reported_unix_milli >= @sinceUnixMilli`,
|
||||
signozMetricDBName,
|
||||
constants.SIGNOZ_METADATA_TABLENAME,
|
||||
)
|
||||
|
||||
rows, err := r.db.Query(ctx, query,
|
||||
clickhouse.Named("metricNames", metricNames),
|
||||
clickhouse.Named("attrName", hostNameAttr),
|
||||
clickhouse.Named("sinceUnixMilli", sinceUnixMilli),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errorsV2.WrapInternalf(err, errorsV2.CodeInternal, "error querying active hosts")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var hostName string
|
||||
if err := rows.Scan(&hostName); err != nil {
|
||||
return nil, errorsV2.WrapInternalf(err, errorsV2.CodeInternal, "error scanning active host row")
|
||||
}
|
||||
if hostName != "" {
|
||||
activeHosts[hostName] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errorsV2.WrapInternalf(err, errorsV2.CodeInternal, "error iterating active host rows")
|
||||
}
|
||||
|
||||
return activeHosts, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetLatestReceivedMetric(
|
||||
ctx context.Context, metricNames []string, labelValues map[string]string,
|
||||
) (*model.MetricStatus, *model.ApiError) {
|
||||
|
||||
@@ -10,55 +10,110 @@ import (
|
||||
)
|
||||
|
||||
var dotMetricMap = map[string]string{
|
||||
"system_filesystem_usage": "system.filesystem.usage",
|
||||
"system_cpu_time": "system.cpu.time",
|
||||
"system_memory_usage": "system.memory.usage",
|
||||
"system_cpu_load_average_15m": "system.cpu.load_average.15m",
|
||||
"host_name": "host.name",
|
||||
"k8s_cluster_name": "k8s.cluster.name",
|
||||
"k8s_node_name": "k8s.node.name",
|
||||
"k8s_pod_memory_usage": "k8s.pod.memory.usage",
|
||||
"k8s_pod_cpu_request_utilization": "k8s.pod.cpu_request_utilization",
|
||||
"k8s_pod_memory_request_utilization": "k8s.pod.memory_request_utilization",
|
||||
"k8s_pod_cpu_limit_utilization": "k8s.pod.cpu_limit_utilization",
|
||||
"k8s_pod_memory_limit_utilization": "k8s.pod.memory_limit_utilization",
|
||||
"k8s_container_restarts": "k8s.container.restarts",
|
||||
"k8s_pod_phase": "k8s.pod.phase",
|
||||
"k8s_node_allocatable_cpu": "k8s.node.allocatable_cpu",
|
||||
"k8s_node_allocatable_memory": "k8s.node.allocatable_memory",
|
||||
"k8s_node_memory_usage": "k8s.node.memory.usage",
|
||||
"k8s_node_condition_ready": "k8s.node.condition_ready",
|
||||
"k8s_daemonset_desired_scheduled_nodes": "k8s.daemonset.desired_scheduled_nodes",
|
||||
"k8s_daemonset_current_scheduled_nodes": "k8s.daemonset.current_scheduled_nodes",
|
||||
"k8s_deployment_desired": "k8s.deployment.desired",
|
||||
"k8s_deployment_available": "k8s.deployment.available",
|
||||
"k8s_job_desired_successful_pods": "k8s.job.desired_successful_pods",
|
||||
"k8s_job_active_pods": "k8s.job.active_pods",
|
||||
"k8s_job_failed_pods": "k8s.job.failed_pods",
|
||||
"k8s_job_successful_pods": "k8s.job.successful_pods",
|
||||
"k8s_statefulset_desired_pods": "k8s.statefulset.desired_pods",
|
||||
"k8s_statefulset_current_pods": "k8s.statefulset.current_pods",
|
||||
"k8s_namespace_name": "k8s.namespace.name",
|
||||
"k8s_deployment_name": "k8s.deployment.name",
|
||||
"k8s_cronjob_name": "k8s.cronjob.name",
|
||||
"k8s_job_name": "k8s.job.name",
|
||||
"k8s_daemonset_name": "k8s.daemonset.name",
|
||||
"os_type": "os.type",
|
||||
"process_cgroup": "process.cgroup",
|
||||
"process_pid": "process.pid",
|
||||
"process_parent_pid": "process.parent_pid",
|
||||
"process_owner": "process.owner",
|
||||
"process_executable_path": "process.executable.path",
|
||||
"process_executable_name": "process.executable.name",
|
||||
"process_command_line": "process.command_line",
|
||||
"process_command": "process.command",
|
||||
"process_memory_usage": "process.memory.usage",
|
||||
"k8s_persistentvolumeclaim_name": "k8s.persistentvolumeclaim.name",
|
||||
"k8s_volume_available": "k8s.volume.available",
|
||||
"k8s_volume_capacity": "k8s.volume.capacity",
|
||||
"k8s_volume_inodes": "k8s.volume.inodes",
|
||||
"k8s_volume_inodes_free": "k8s.volume.inodes.free",
|
||||
// add additional mappings as needed
|
||||
"system_uptime": "system.uptime",
|
||||
"system_cpu_physical_count": "system.cpu.physical.count",
|
||||
"system_cpu_logical_count": "system.cpu.logical.count",
|
||||
"system_cpu_time": "system.cpu.time",
|
||||
"system_cpu_frequency": "system.cpu.frequency",
|
||||
"system_cpu_utilization": "system.cpu.utilization",
|
||||
"system_cpu_load_average_15m": "system.cpu.load_average.15m",
|
||||
"system_memory_usage": "system.memory.usage",
|
||||
"system_memory_limit": "system.memory.limit",
|
||||
"system_memory_utilization": "system.memory.utilization",
|
||||
"system_memory_linux_available": "system.memory.linux.available",
|
||||
"system_memory_linux_shared": "system.memory.linux.shared",
|
||||
"system_memory_linux_slab_usage": "system.memory.linux.slab.usage",
|
||||
"system_paging_usage": "system.paging.usage",
|
||||
"system_paging_utilization": "system.paging.utilization",
|
||||
"system_paging_faults": "system.paging.faults",
|
||||
"system_paging_operations": "system.paging.operations",
|
||||
"system_disk_io": "system.disk.io",
|
||||
"system_disk_operations": "system.disk.operations",
|
||||
"system_disk_io_time": "system.disk.io_time",
|
||||
"system_disk_operation_time": "system.disk.operation_time",
|
||||
"system_disk_merged": "system.disk.merged",
|
||||
"system_disk_limit": "system.disk.limit",
|
||||
"system_filesystem_usage": "system.filesystem.usage",
|
||||
"system_filesystem_utilization": "system.filesystem.utilization",
|
||||
"system_filesystem_limit": "system.filesystem.limit",
|
||||
"system_network_errors": "system.network.errors",
|
||||
"system_network_io": "system.network.io",
|
||||
"system_network_connections": "system.network.connections",
|
||||
"system_network_dropped": "system.network.dropped",
|
||||
"system_network_packets": "system.network.packets",
|
||||
"system_processes_count": "system.processes.count",
|
||||
"system_processes_created": "system.processes.created",
|
||||
"system_disk_pending_operations": "system.disk.pending_operations",
|
||||
"system_disk_weighted_io_time": "system.disk.weighted_io_time",
|
||||
"system_filesystem_inodes_usage": "system.filesystem.inodes.usage",
|
||||
"system_network_conntrack_count": "system.network.conntrack.count",
|
||||
"system_network_conntrack_max": "system.network.conntrack.max",
|
||||
"system_cpu_load_average_1m": "system.cpu.load_average.1m",
|
||||
"system_cpu_load_average_5m": "system.cpu.load_average.5m",
|
||||
|
||||
"host_name": "host.name",
|
||||
"k8s_cluster_name": "k8s.cluster.name",
|
||||
"k8s_node_name": "k8s.node.name",
|
||||
"k8s_pod_memory_usage": "k8s.pod.memory.usage",
|
||||
"k8s_pod_cpu_request_utilization": "k8s.pod.cpu_request_utilization",
|
||||
"k8s_pod_memory_request_utilization": "k8s.pod.memory_request_utilization",
|
||||
"k8s_pod_cpu_limit_utilization": "k8s.pod.cpu_limit_utilization",
|
||||
"k8s_pod_memory_limit_utilization": "k8s.pod.memory_limit_utilization",
|
||||
"k8s_container_restarts": "k8s.container.restarts",
|
||||
"k8s_pod_phase": "k8s.pod.phase",
|
||||
"k8s_node_allocatable_cpu": "k8s.node.allocatable_cpu",
|
||||
"k8s_node_allocatable_memory": "k8s.node.allocatable_memory",
|
||||
"k8s_node_memory_usage": "k8s.node.memory.usage",
|
||||
"k8s_node_condition_ready": "k8s.node.condition_ready",
|
||||
"k8s_daemonset_desired_scheduled_nodes": "k8s.daemonset.desired_scheduled_nodes",
|
||||
"k8s_daemonset_current_scheduled_nodes": "k8s.daemonset.current_scheduled_nodes",
|
||||
"k8s_deployment_desired": "k8s.deployment.desired",
|
||||
"k8s_deployment_available": "k8s.deployment.available",
|
||||
"k8s_job_desired_successful_pods": "k8s.job.desired_successful_pods",
|
||||
"k8s_job_active_pods": "k8s.job.active_pods",
|
||||
"k8s_job_failed_pods": "k8s.job.failed_pods",
|
||||
"k8s_job_successful_pods": "k8s.job.successful_pods",
|
||||
"k8s_statefulset_desired_pods": "k8s.statefulset.desired_pods",
|
||||
"k8s_statefulset_current_pods": "k8s.statefulset.current_pods",
|
||||
"k8s_namespace_name": "k8s.namespace.name",
|
||||
"k8s_deployment_name": "k8s.deployment.name",
|
||||
"k8s_cronjob_name": "k8s.cronjob.name",
|
||||
"k8s_job_name": "k8s.job.name",
|
||||
"k8s_daemonset_name": "k8s.daemonset.name",
|
||||
"os_type": "os.type",
|
||||
"process_cgroup": "process.cgroup",
|
||||
"process_pid": "process.pid",
|
||||
"process_parent_pid": "process.parent_pid",
|
||||
"process_owner": "process.owner",
|
||||
"process_executable_path": "process.executable.path",
|
||||
"process_executable_name": "process.executable.name",
|
||||
"process_command_line": "process.command_line",
|
||||
"process_command": "process.command",
|
||||
"process_memory_usage": "process.memory.usage",
|
||||
"process_memory_virtual": "process.memory.virtual",
|
||||
"process_cpu_time": "process.cpu.time",
|
||||
"process_disk_io": "process.disk.io",
|
||||
"nfs_client_net_count": "nfs.client.net.count",
|
||||
"nfs_client_net_tcp_connection_accepted": "nfs.client.net.tcp.connection.accepted",
|
||||
"nfs_client_operation_count": "nfs.client.operation.count",
|
||||
"nfs_client_procedure_count": "nfs.client.procedure.count",
|
||||
"nfs_client_rpc_authrefresh_count": "nfs.client.rpc.authrefresh.count",
|
||||
"nfs_client_rpc_count": "nfs.client.rpc.count",
|
||||
"nfs_client_rpc_retransmit_count": "nfs.client.rpc.retransmit.count",
|
||||
"nfs_server_fh_stale_count": "nfs.server.fh.stale.count",
|
||||
"nfs_server_io": "nfs.server.io",
|
||||
"nfs_server_net_count": "nfs.server.net.count",
|
||||
"nfs_server_net_tcp_connection_accepted": "nfs.server.net.tcp.connection.accepted",
|
||||
"nfs_server_operation_count": "nfs.server.operation.count",
|
||||
"nfs_server_procedure_count": "nfs.server.procedure.count",
|
||||
"nfs_server_repcache_requests": "nfs.server.repcache.requests",
|
||||
"nfs_server_rpc_count": "nfs.server.rpc.count",
|
||||
"nfs_server_thread_count": "nfs.server.thread.count",
|
||||
"k8s_persistentvolumeclaim_name": "k8s.persistentvolumeclaim.name",
|
||||
"k8s_volume_available": "k8s.volume.available",
|
||||
"k8s_volume_capacity": "k8s.volume.capacity",
|
||||
"k8s_volume_inodes": "k8s.volume.inodes",
|
||||
"k8s_volume_inodes_free": "k8s.volume.inodes.free",
|
||||
|
||||
"k8s_pod_uid": "k8s.pod.uid",
|
||||
"k8s_pod_name": "k8s.pod.name",
|
||||
|
||||
@@ -73,6 +73,53 @@ var (
|
||||
"load15": GetDotMetrics("system_cpu_load_average_15m"),
|
||||
"wait": GetDotMetrics("system_cpu_time"),
|
||||
}
|
||||
uniqueMetricNamesForHosts = []string{
|
||||
GetDotMetrics("system_uptime"),
|
||||
GetDotMetrics("system_cpu_time"),
|
||||
GetDotMetrics("system_cpu_load_average_1m"),
|
||||
GetDotMetrics("system_cpu_load_average_5m"),
|
||||
GetDotMetrics("system_cpu_load_average_15m"),
|
||||
GetDotMetrics("system_memory_usage"),
|
||||
GetDotMetrics("system_paging_usage"),
|
||||
GetDotMetrics("system_paging_faults"),
|
||||
GetDotMetrics("system_paging_operations"),
|
||||
GetDotMetrics("system_disk_io"),
|
||||
GetDotMetrics("system_disk_operations"),
|
||||
GetDotMetrics("system_disk_io_time"),
|
||||
GetDotMetrics("system_disk_operation_time"),
|
||||
GetDotMetrics("system_disk_merged"),
|
||||
GetDotMetrics("system_disk_pending_operations"),
|
||||
GetDotMetrics("system_disk_weighted_io_time"),
|
||||
GetDotMetrics("system_filesystem_usage"),
|
||||
GetDotMetrics("system_filesystem_inodes_usage"),
|
||||
GetDotMetrics("system_network_io"),
|
||||
GetDotMetrics("system_network_errors"),
|
||||
GetDotMetrics("system_network_connections"),
|
||||
GetDotMetrics("system_network_dropped"),
|
||||
GetDotMetrics("system_network_packets"),
|
||||
GetDotMetrics("system_processes_count"),
|
||||
GetDotMetrics("system_processes_created"),
|
||||
GetDotMetrics("process_cpu_time"),
|
||||
GetDotMetrics("process_disk_io"),
|
||||
GetDotMetrics("process_memory_usage"),
|
||||
GetDotMetrics("process_memory_virtual"),
|
||||
GetDotMetrics("nfs_client_net_count"),
|
||||
GetDotMetrics("nfs_client_net_tcp_connection_accepted"),
|
||||
GetDotMetrics("nfs_client_operation_count"),
|
||||
GetDotMetrics("nfs_client_procedure_count"),
|
||||
GetDotMetrics("nfs_client_rpc_authrefresh_count"),
|
||||
GetDotMetrics("nfs_client_rpc_count"),
|
||||
GetDotMetrics("nfs_client_rpc_retransmit_count"),
|
||||
GetDotMetrics("nfs_server_fh_stale_count"),
|
||||
GetDotMetrics("nfs_server_io"),
|
||||
GetDotMetrics("nfs_server_net_count"),
|
||||
GetDotMetrics("nfs_server_net_tcp_connection_accepted"),
|
||||
GetDotMetrics("nfs_server_operation_count"),
|
||||
GetDotMetrics("nfs_server_procedure_count"),
|
||||
GetDotMetrics("nfs_server_repcache_requests"),
|
||||
GetDotMetrics("nfs_server_rpc_count"),
|
||||
GetDotMetrics("nfs_server_thread_count"),
|
||||
}
|
||||
)
|
||||
|
||||
func NewHostsRepo(reader interfaces.Reader, querierV2 interfaces.Querier) *HostsRepo {
|
||||
@@ -131,62 +178,9 @@ func (h *HostsRepo) GetHostAttributeValues(ctx context.Context, req v3.FilterAtt
|
||||
return &v3.FilterAttributeValueResponse{StringAttributeValues: hostNames}, nil
|
||||
}
|
||||
|
||||
func (h *HostsRepo) getActiveHosts(ctx context.Context, orgID valuer.UUID, req model.HostListRequest) (map[string]bool, error) {
|
||||
activeStatus := map[string]bool{}
|
||||
step := common.MinAllowedStepInterval(req.Start, req.End)
|
||||
|
||||
hasHostName := false
|
||||
for _, key := range req.GroupBy {
|
||||
if key.Key == hostNameAttrKey {
|
||||
hasHostName = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasHostName {
|
||||
req.GroupBy = append(req.GroupBy, v3.AttributeKey{Key: hostNameAttrKey})
|
||||
}
|
||||
|
||||
params := v3.QueryRangeParamsV3{
|
||||
Start: time.Now().Add(-time.Minute * 10).UTC().UnixMilli(),
|
||||
End: time.Now().UTC().UnixMilli(),
|
||||
Step: step,
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
StepInterval: step,
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateAttribute: v3.AttributeKey{
|
||||
Key: metricToUseForHostAttributes,
|
||||
DataType: v3.AttributeKeyDataTypeFloat64,
|
||||
},
|
||||
Temporality: v3.Unspecified,
|
||||
Filters: req.Filters,
|
||||
GroupBy: req.GroupBy,
|
||||
Expression: "A",
|
||||
TimeAggregation: v3.TimeAggregationAvg,
|
||||
SpaceAggregation: v3.SpaceAggregationAvg,
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
PanelType: v3.PanelTypeGraph,
|
||||
},
|
||||
}
|
||||
|
||||
queryResponse, _, err := h.querierV2.QueryRange(ctx, orgID, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, result := range queryResponse {
|
||||
for _, series := range result.Series {
|
||||
name := series.Labels[hostNameAttrKey]
|
||||
activeStatus[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
return activeStatus, nil
|
||||
func (h *HostsRepo) getActiveHosts(ctx context.Context) (map[string]bool, error) {
|
||||
tenMinAgo := time.Now().Add(-10 * time.Minute).UTC().UnixMilli()
|
||||
return h.reader.GetActiveHostsFromMetricMetadata(ctx, uniqueMetricNamesForHosts, hostNameAttrKey, tenMinAgo)
|
||||
}
|
||||
|
||||
func (h *HostsRepo) getMetadataAttributes(ctx context.Context, req model.HostListRequest) (map[string]map[string]string, error) {
|
||||
@@ -450,7 +444,7 @@ func (h *HostsRepo) GetHostList(ctx context.Context, orgID valuer.UUID, req mode
|
||||
return resp, err
|
||||
}
|
||||
|
||||
activeHosts, err := h.getActiveHosts(ctx, orgID, req)
|
||||
activeHosts, err := h.getActiveHosts(ctx)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ type Reader interface {
|
||||
SubscribeToQueryProgress(queryId string) (<-chan model.QueryProgress, func(), *model.ApiError)
|
||||
|
||||
GetCountOfThings(ctx context.Context, query string) (uint64, error)
|
||||
GetActiveHostsFromMetricMetadata(ctx context.Context, metricNames []string, hostNameAttr string, sinceUnixMilli int64) (map[string]bool, error)
|
||||
|
||||
GetMetricsExistenceAndEarliestTime(ctx context.Context, metricNames []string) (uint64, uint64, error)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user