mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-03 12:32:02 +00:00
Compare commits
8 Commits
light-mode
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09dc325de | ||
|
|
379b4f7fc4 | ||
|
|
5e536ae077 | ||
|
|
234585e642 | ||
|
|
2cc14f1ad4 | ||
|
|
dc4ed4d239 | ||
|
|
7281c36873 | ||
|
|
40288776e8 |
@@ -6167,10 +6167,6 @@ paths:
|
||||
name: searchText
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: source
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
||||
@@ -3451,11 +3451,6 @@ export type ListMetricsParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
searchText?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type ListMetrics200 = {
|
||||
|
||||
@@ -60,30 +60,11 @@
|
||||
gap: 8px;
|
||||
|
||||
margin-left: 108px;
|
||||
position: relative;
|
||||
|
||||
/* Vertical dashed line connecting query elements */
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
.metrics-aggregation-section-content,
|
||||
.metrics-container {
|
||||
.metrics-aggregation-section-content {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
@@ -121,10 +102,6 @@
|
||||
.qb-elements-container {
|
||||
margin-left: 0px;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
@@ -356,7 +333,28 @@
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
content: '';
|
||||
height: 120px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 31px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
&.has-trace-operator {
|
||||
&::before {
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,21 +462,10 @@
|
||||
|
||||
.qb-content-section {
|
||||
.qb-elements-container {
|
||||
&::after {
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
.metrics-aggregation-section-content,
|
||||
.metrics-container {
|
||||
.metrics-aggregation-section-content {
|
||||
&::before {
|
||||
border-left: 6px dotted var(--bg-vanilla-300);
|
||||
}
|
||||
@@ -542,6 +529,18 @@
|
||||
|
||||
.qb-entity-options {
|
||||
.options {
|
||||
.query-name {
|
||||
&::before {
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.formula-name {
|
||||
&::before {
|
||||
background: repeating-linear-gradient(
|
||||
|
||||
@@ -207,7 +207,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
queriesCount={1}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { MetricNameSelector } from 'container/QueryBuilder/filters';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
@@ -43,12 +44,21 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
signalSourceChangeEnabled: boolean;
|
||||
savePreviousQuery: boolean;
|
||||
}): JSX.Element {
|
||||
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
|
||||
const { handleChangeAggregatorAttribute } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
const handleAggregatorAttributeChange = useCallback(
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean) => {
|
||||
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
|
||||
},
|
||||
[handleChangeAggregatorAttribute, attributeKeys],
|
||||
);
|
||||
|
||||
const {
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
@@ -154,10 +164,12 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
/>
|
||||
)}
|
||||
|
||||
<MetricNameSelector
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
<AggregatorFilter
|
||||
onChange={handleAggregatorAttributeChange}
|
||||
query={query}
|
||||
index={index}
|
||||
signalSource={signalSource || ''}
|
||||
setAttributeKeys={setAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -202,8 +202,8 @@ function QueryAddOns({
|
||||
} else {
|
||||
filteredAddOns = Object.values(ADD_ONS);
|
||||
|
||||
// Filter out group_by for metrics data source
|
||||
if (query.dataSource === DataSource.METRICS) {
|
||||
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
|
||||
filteredAddOns = filteredAddOns.filter(
|
||||
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
|
||||
);
|
||||
|
||||
@@ -43,7 +43,6 @@ jest.mock(
|
||||
);
|
||||
jest.mock('container/QueryBuilder/filters', () => ({
|
||||
AggregatorFilter: (): JSX.Element => <div />,
|
||||
MetricNameSelector: (): JSX.Element => <div />,
|
||||
}));
|
||||
// Mock hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum LOCALSTORAGE {
|
||||
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
BANNER_DISMISSED = 'BANNER_DISMISSED',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
@@ -178,7 +177,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -226,7 +225,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: MeterAggregateOperator.AVG,
|
||||
timeAggregation: MeterAggregateOperator.COUNT,
|
||||
spaceAggregation: MeterAggregateOperator.SUM,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -372,31 +371,6 @@ export enum ATTRIBUTE_TYPES {
|
||||
EXPONENTIAL_HISTOGRAM = 'ExponentialHistogram',
|
||||
}
|
||||
|
||||
const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
|
||||
MetrictypesTypeDTO,
|
||||
ATTRIBUTE_TYPES
|
||||
> = {
|
||||
[MetrictypesTypeDTO.sum]: ATTRIBUTE_TYPES.SUM,
|
||||
[MetrictypesTypeDTO.gauge]: ATTRIBUTE_TYPES.GAUGE,
|
||||
[MetrictypesTypeDTO.histogram]: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
[MetrictypesTypeDTO.summary]: ATTRIBUTE_TYPES.GAUGE,
|
||||
[MetrictypesTypeDTO.exponentialhistogram]:
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
};
|
||||
|
||||
export function toAttributeType(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
isMonotonic?: boolean,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
if (!metricType) {
|
||||
return '';
|
||||
}
|
||||
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
|
||||
return ATTRIBUTE_TYPES.GAUGE;
|
||||
}
|
||||
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
|
||||
}
|
||||
|
||||
export type IQueryBuilderState = 'search';
|
||||
|
||||
export const QUERY_BUILDER_SEARCH_VALUES = {
|
||||
|
||||
@@ -179,19 +179,6 @@
|
||||
&__input.description {
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
color: var(--text-ink-400);
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--text-ink-500);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-alert-header {
|
||||
|
||||
@@ -441,7 +441,7 @@ describe('Footer utils', () => {
|
||||
reduceTo: undefined,
|
||||
spaceAggregation: 'sum',
|
||||
temporality: undefined,
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -9,7 +8,7 @@ interface BaseChartProps {
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone?: Timezone;
|
||||
timezone: string;
|
||||
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,7 +5,12 @@ 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 } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { get } from 'lodash-es';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
@@ -58,12 +63,7 @@ export function prepareBarPanelConfig({
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
id: widget.id,
|
||||
thresholds: widget.thresholds,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
isLogScale: widget.isLogScale,
|
||||
widget,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -98,8 +98,14 @@ 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}
|
||||
timezone={timezone.value}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -154,12 +154,7 @@ export function prepareHistogramPanelConfig({
|
||||
isDarkMode: boolean;
|
||||
}): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
id: widget.id,
|
||||
thresholds: widget.thresholds,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
isLogScale: widget.isLogScale,
|
||||
widget,
|
||||
isDarkMode,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
@@ -196,8 +191,10 @@ export function prepareHistogramPanelConfig({
|
||||
builder.addSeries({
|
||||
label: '',
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
pointSize: 5,
|
||||
lineColor: '#3f5ecc',
|
||||
@@ -219,8 +216,10 @@ export function prepareHistogramPanelConfig({
|
||||
builder.addSeries({
|
||||
label: label,
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.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}
|
||||
timezone={timezone.value}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -82,12 +82,7 @@ export const prepareUPlotConfig = ({
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
id: widget.id,
|
||||
thresholds: widget.thresholds,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
isLogScale: widget.isLogScale,
|
||||
widget,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -125,6 +120,7 @@ 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 { BaseConfigBuilderProps, buildBaseConfig } from '../baseConfigBuilder';
|
||||
import { buildBaseConfig } from '../baseConfigBuilder';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
@@ -27,25 +27,16 @@ jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
|
||||
}));
|
||||
|
||||
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 createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
|
||||
({
|
||||
id: 'widget-1',
|
||||
yAxisUnit: 'ms',
|
||||
isLogScale: false,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
thresholds: [],
|
||||
...overrides,
|
||||
} as Widgets);
|
||||
|
||||
const createApiResponse = (
|
||||
overrides: Partial<MetricRangePayloadProps> = {},
|
||||
@@ -56,7 +47,7 @@ const createApiResponse = (
|
||||
} as MetricRangePayloadProps);
|
||||
|
||||
const baseProps = {
|
||||
...createBaseConfigBuilderProps(),
|
||||
widget: createWidget(),
|
||||
apiResponse: createApiResponse(),
|
||||
isDarkMode: true,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
@@ -72,14 +63,14 @@ describe('buildBaseConfig', () => {
|
||||
expect(typeof builder.getLegendItems).toBe('function');
|
||||
});
|
||||
|
||||
it('configures builder with id and DASHBOARD_VIEW preferences', () => {
|
||||
it('configures builder with widgetId and DASHBOARD_VIEW preferences', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
...createBaseConfigBuilderProps({ id: 'my-widget' }),
|
||||
widget: createWidget({ id: 'my-widget' }),
|
||||
});
|
||||
|
||||
expect(builder.getId()).toBe('my-widget');
|
||||
expect(builder.getWidgetId()).toBe('my-widget');
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -136,7 +127,7 @@ describe('buildBaseConfig', () => {
|
||||
it('configures log scale on y axis when widget.isLogScale is true', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
...createBaseConfigBuilderProps({ isLogScale: true }),
|
||||
widget: createWidget({ isLogScale: true }),
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
@@ -180,7 +171,7 @@ describe('buildBaseConfig', () => {
|
||||
it('adds thresholds from widget', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
...createBaseConfigBuilderProps({
|
||||
widget: createWidget({
|
||||
thresholds: [
|
||||
{
|
||||
thresholdValue: 80,
|
||||
@@ -188,7 +179,7 @@ describe('buildBaseConfig', () => {
|
||||
thresholdUnit: 'ms',
|
||||
thresholdLabel: 'High',
|
||||
},
|
||||
] as ThresholdProps[],
|
||||
] as Widgets['thresholds'],
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -10,32 +9,28 @@ 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 {
|
||||
id: string;
|
||||
thresholds?: ThresholdProps[];
|
||||
widget: Widgets;
|
||||
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({
|
||||
id,
|
||||
widget,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -43,14 +38,9 @@ export function buildBaseConfig({
|
||||
timezone,
|
||||
panelMode,
|
||||
panelType,
|
||||
thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
stepInterval,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
softMin,
|
||||
softMax,
|
||||
}: BaseConfigBuilderProps): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
@@ -58,27 +48,28 @@ export function buildBaseConfig({
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
onDragSelect,
|
||||
widgetId: widget.id,
|
||||
tzDate,
|
||||
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
|
||||
selectionPreferencesSource: panelMode
|
||||
? [PanelMode.DASHBOARD_VIEW, PanelMode.STANDALONE_VIEW].includes(panelMode)
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY
|
||||
selectionPreferencesSource: [
|
||||
PanelMode.DASHBOARD_VIEW,
|
||||
PanelMode.STANDALONE_VIEW,
|
||||
].includes(panelMode)
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY,
|
||||
stepInterval,
|
||||
});
|
||||
|
||||
const thresholdOptions: ThresholdsDrawHookOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: (thresholds || []).map((threshold) => ({
|
||||
thresholds: (widget.thresholds || []).map((threshold) => ({
|
||||
thresholdValue: threshold.thresholdValue ?? 0,
|
||||
thresholdColor: threshold.thresholdColor,
|
||||
thresholdUnit: threshold.thresholdUnit,
|
||||
thresholdLabel: threshold.thresholdLabel,
|
||||
})),
|
||||
yAxisUnit: yAxisUnit,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdOptions);
|
||||
@@ -88,8 +79,8 @@ export function buildBaseConfig({
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
@@ -100,11 +91,11 @@ export function buildBaseConfig({
|
||||
time: false,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: softMin,
|
||||
softMax: softMax,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
thresholds: thresholdOptions,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
@@ -123,7 +114,7 @@ export function buildBaseConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
isLogScale: widget.isLogScale,
|
||||
panelType,
|
||||
});
|
||||
|
||||
@@ -132,8 +123,8 @@ export function buildBaseConfig({
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
isLogScale: widget.isLogScale,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,51 @@
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.home-container-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
background-color: var(--bg-robin-500);
|
||||
|
||||
.home-container-banner-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.home-container-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
.home-container-banner-link {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
|
||||
import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import Header from 'components/Header/Header';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -15,8 +15,10 @@ import ROUTES from 'constants/routes';
|
||||
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { CompassIcon, DotIcon, HomeIcon, Plus, Wrench, X } from 'lucide-react';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import * as motion from 'motion/react-client';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
@@ -49,6 +51,8 @@ export default function Home(): JSX.Element {
|
||||
const [updatingUserPreferences, setUpdatingUserPreferences] = useState(false);
|
||||
const [loadingUserPreferences, setLoadingUserPreferences] = useState(true);
|
||||
|
||||
const { isCommunityUser, isCommunityEnterpriseUser } = useGetTenantLicense();
|
||||
|
||||
const [checklistItems, setChecklistItems] = useState<ChecklistItem[]>(
|
||||
defaultChecklistItemsState,
|
||||
);
|
||||
@@ -57,6 +61,13 @@ export default function Home(): JSX.Element {
|
||||
false,
|
||||
);
|
||||
|
||||
const [isBannerDismissed, setIsBannerDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const bannerDismissed = localStorage.getItem(LOCALSTORAGE.BANNER_DISMISSED);
|
||||
setIsBannerDismissed(bannerDismissed === 'true');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const startTime = new Date(now.getTime() - homeInterval);
|
||||
@@ -287,13 +298,44 @@ export default function Home(): JSX.Element {
|
||||
logEvent('Homepage: Visited', {});
|
||||
}, []);
|
||||
|
||||
const hideBanner = (): void => {
|
||||
localStorage.setItem(LOCALSTORAGE.BANNER_DISMISSED, 'true');
|
||||
setIsBannerDismissed(true);
|
||||
};
|
||||
|
||||
const showBanner = useMemo(
|
||||
() => !isBannerDismissed && (isCommunityUser || isCommunityEnterpriseUser),
|
||||
[isBannerDismissed, isCommunityUser, isCommunityEnterpriseUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<div className="sticky-header">
|
||||
{showBanner && (
|
||||
<div className="home-container-banner">
|
||||
<div className="home-container-banner-content">
|
||||
Big News: SigNoz Community Edition now available with SSO (Google OAuth)
|
||||
and API keys -
|
||||
<a
|
||||
href="https://signoz.io/blog/open-source-signoz-now-available-with-sso-and-api-keys/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="home-container-banner-link"
|
||||
>
|
||||
<i>read more</i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="home-container-banner-close">
|
||||
<X size={16} onClick={hideBanner} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Header
|
||||
leftComponent={
|
||||
<div className="home-header-left">
|
||||
<House size={14} /> Home
|
||||
<HomeIcon size={14} /> Home
|
||||
</div>
|
||||
}
|
||||
rightComponent={
|
||||
@@ -358,7 +400,7 @@ export default function Home(): JSX.Element {
|
||||
<div className="active-ingestion-card-content-container">
|
||||
<div className="active-ingestion-card-content">
|
||||
<div className="active-ingestion-card-content-icon">
|
||||
<Dot size={16} color={Color.BG_FOREST_500} />
|
||||
<DotIcon size={16} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
|
||||
<div className="active-ingestion-card-content-description">
|
||||
@@ -385,7 +427,7 @@ export default function Home(): JSX.Element {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Compass size={12} />
|
||||
<CompassIcon size={12} />
|
||||
Explore Logs
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,7 +441,7 @@ export default function Home(): JSX.Element {
|
||||
<div className="active-ingestion-card-content-container">
|
||||
<div className="active-ingestion-card-content">
|
||||
<div className="active-ingestion-card-content-icon">
|
||||
<Dot size={16} color={Color.BG_FOREST_500} />
|
||||
<DotIcon size={16} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
|
||||
<div className="active-ingestion-card-content-description">
|
||||
@@ -426,7 +468,7 @@ export default function Home(): JSX.Element {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Compass size={12} />
|
||||
<CompassIcon size={12} />
|
||||
Explore Traces
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,7 +482,7 @@ export default function Home(): JSX.Element {
|
||||
<div className="active-ingestion-card-content-container">
|
||||
<div className="active-ingestion-card-content">
|
||||
<div className="active-ingestion-card-content-icon">
|
||||
<Dot size={16} color={Color.BG_FOREST_500} />
|
||||
<DotIcon size={16} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
|
||||
<div className="active-ingestion-card-content-description">
|
||||
@@ -467,7 +509,7 @@ export default function Home(): JSX.Element {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Compass size={12} />
|
||||
<CompassIcon size={12} />
|
||||
Explore Metrics
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { isAxiosError } from 'axios';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
@@ -116,34 +115,27 @@ function TimeSeries(): JSX.Element {
|
||||
setYAxisUnit(value);
|
||||
};
|
||||
|
||||
const hasMetricSelected = useMemo(
|
||||
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
|
||||
[currentQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="meter-time-series-container">
|
||||
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
|
||||
<div className="time-series-container">
|
||||
{!hasMetricSelected && <EmptyMetricsSearch />}
|
||||
{hasMetricSelected &&
|
||||
responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
dataSource={DataSource.METRICS}
|
||||
yAxisUnit={yAxisUnit}
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
dataSource={DataSource.METRICS}
|
||||
yAxisUnit={yAxisUnit}
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { Typography } from 'antd';
|
||||
import { Empty } from 'antd/lib';
|
||||
|
||||
interface EmptyMetricsSearchProps {
|
||||
hasQueryResult?: boolean;
|
||||
}
|
||||
|
||||
export default function EmptyMetricsSearch({
|
||||
hasQueryResult,
|
||||
}: EmptyMetricsSearchProps): JSX.Element {
|
||||
export default function EmptyMetricsSearch(): JSX.Element {
|
||||
return (
|
||||
<div className="empty-metrics-search">
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Title level={5}>
|
||||
{hasQueryResult
|
||||
? 'No data'
|
||||
: 'Select a metric and run a query to see the results'}
|
||||
Please build and run a valid query to see the result
|
||||
</Typography.Title>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ function Explorer(): JSX.Element {
|
||||
!isMetricUnitsLoading &&
|
||||
!isMetricUnitsError &&
|
||||
units.length > 0 &&
|
||||
units.every((unit) => unit === units[0]),
|
||||
units.every((unit) => unit && unit === units[0]),
|
||||
[units, isMetricUnitsLoading, isMetricUnitsError],
|
||||
);
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import EmptyMetricsSearch from './EmptyMetricsSearch';
|
||||
import { TimeSeriesProps } from './types';
|
||||
import {
|
||||
buildUpdateMetricYAxisUnitPayload,
|
||||
@@ -210,7 +209,7 @@ function TimeSeries({
|
||||
{showSaveUnitButton && (
|
||||
<div className="save-unit-container">
|
||||
<Typography.Text>
|
||||
Set the selected unit as the metric unit?
|
||||
Save the selected unit for this metric?
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -230,71 +229,64 @@ function TimeSeries({
|
||||
'time-series-container': changeLayoutForOneChartPerQuery,
|
||||
})}
|
||||
>
|
||||
{metricNames.length === 0 && <EmptyMetricsSearch />}
|
||||
{metricNames.length > 0 &&
|
||||
responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
|
||||
{responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
|
||||
|
||||
// Show the no unit warning if -
|
||||
// 1. The metric query is not loading
|
||||
// 2. The metric units are not loading
|
||||
// 3. There are more than one metric
|
||||
// 4. The current metric unit is empty
|
||||
// 5. Is a queryData item
|
||||
const isMetricUnitEmpty =
|
||||
isQueryDataItem &&
|
||||
!queries[index].isLoading &&
|
||||
!isMetricUnitsLoading &&
|
||||
metricUnits.length > 1 &&
|
||||
!metricUnit &&
|
||||
metricName;
|
||||
// Show the no unit warning if -
|
||||
// 1. The metric query is not loading
|
||||
// 2. The metric units are not loading
|
||||
// 3. There are more than one metric
|
||||
// 4. The current metric unit is empty
|
||||
// 5. Is a queryData item
|
||||
const isMetricUnitEmpty =
|
||||
isQueryDataItem &&
|
||||
!queries[index].isLoading &&
|
||||
!isMetricUnitsLoading &&
|
||||
metricUnits.length > 1 &&
|
||||
!metricUnit &&
|
||||
metricName;
|
||||
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="time-series-view"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
{isMetricUnitEmpty && metricName && (
|
||||
<Tooltip
|
||||
className="no-unit-warning"
|
||||
title={
|
||||
<Typography.Text>
|
||||
No unit is set for this metric. You can assign one from the{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => handleOpenMetricDetails(metricName)}
|
||||
>
|
||||
metric details
|
||||
</Typography.Link>{' '}
|
||||
page.
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
color={Color.BG_AMBER_400}
|
||||
role="img"
|
||||
aria-label="no unit warning"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading || isMetricUnitsLoading}
|
||||
data={datapoint}
|
||||
yAxisUnit={currentYAxisUnit}
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
className="time-series-view"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
{isMetricUnitEmpty && metricName && (
|
||||
<Tooltip
|
||||
className="no-unit-warning"
|
||||
title={
|
||||
<Typography.Text>
|
||||
This metric does not have a unit. Please set one for it in the{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => handleOpenMetricDetails(metricName)}
|
||||
>
|
||||
metric details
|
||||
</Typography.Link>{' '}
|
||||
page.
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading || isMetricUnitsLoading}
|
||||
data={datapoint}
|
||||
yAxisUnit={currentYAxisUnit}
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import EmptyMetricsSearch from '../EmptyMetricsSearch';
|
||||
|
||||
describe('EmptyMetricsSearch', () => {
|
||||
it('shows select metric message when no query has been run', () => {
|
||||
render(<EmptyMetricsSearch />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Select a metric and run a query to see the results'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no data message when a query returned empty results', () => {
|
||||
render(<EmptyMetricsSearch hasQueryResult />);
|
||||
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import * as useOptionsMenuHooks from 'container/OptionsMenu';
|
||||
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
|
||||
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -157,6 +157,26 @@ describe('Explorer', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render Explorer query builder with metrics datasource selected', () => {
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: initialQueriesMap[DataSource.TRACES],
|
||||
} as any);
|
||||
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
DataSource.METRICS,
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable one chart per query toggle when oneChartPerQuery=true in URL', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
|
||||
@@ -221,46 +241,20 @@ describe('Explorer', () => {
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('one chart per query toggle should be forced on and disabled when multiple metrics have different units', () => {
|
||||
const mockQueryData = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'metric1',
|
||||
},
|
||||
};
|
||||
const mockStagedQueryWithMultipleQueries = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [mockQueryData, mockQueryData],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: mockStagedQueryWithMultipleQueries,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
it('should hide y axis unit selector for multiple metrics with different units', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [
|
||||
{ ...MOCK_METRIC_METADATA, unit: 'seconds' },
|
||||
{ ...MOCK_METRIC_METADATA, unit: 'bytes' },
|
||||
],
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
});
|
||||
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
|
||||
// One chart per query toggle should be disabled
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
expect(oneChartPerQueryToggle).toBeChecked();
|
||||
expect(oneChartPerQueryToggle).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -333,53 +327,4 @@ describe('Explorer', () => {
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
expect(oneChartPerQueryToggle).toBeEnabled();
|
||||
});
|
||||
|
||||
it('one chart per query toggle should be enabled when multiple metrics have no unit', () => {
|
||||
const metricWithNoUnit = {
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
description: 'metric without unit',
|
||||
unit: '',
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
isMonotonic: true,
|
||||
};
|
||||
const mockQueryData = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'metric1',
|
||||
},
|
||||
};
|
||||
const mockStagedQueryWithMultipleQueries = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [mockQueryData, mockQueryData],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: mockStagedQueryWithMultipleQueries,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [metricWithNoUnit, metricWithNoUnit],
|
||||
});
|
||||
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
// Toggle should be enabled (not forced/disabled) since both metrics
|
||||
// have the same unit (no unit) and should be viewable on the same graph
|
||||
expect(oneChartPerQueryToggle).toBeEnabled();
|
||||
expect(oneChartPerQueryToggle).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
|
||||
@@ -56,7 +56,7 @@ const mockSetYAxisUnit = jest.fn();
|
||||
|
||||
function renderTimeSeries(
|
||||
overrides: Partial<TimeSeriesProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
): RenderResult {
|
||||
return render(
|
||||
<TimeSeries
|
||||
showOneChartPerQuery={false}
|
||||
@@ -84,57 +84,45 @@ describe('TimeSeries', () => {
|
||||
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
|
||||
});
|
||||
|
||||
it('shows select metric message when no metric is selected', () => {
|
||||
renderTimeSeries({ metricNames: [] });
|
||||
|
||||
expect(
|
||||
screen.getByText('Select a metric and run a query to see the results'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('TimeSeriesView')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chart view when a metric is selected', () => {
|
||||
renderTimeSeries({
|
||||
metricNames: ['metric1'],
|
||||
metricUnits: ['count'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
});
|
||||
|
||||
expect(screen.getByText('TimeSeriesView')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Select a metric and run a query to see the results'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
|
||||
renderTimeSeries({
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [undefined, undefined],
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'no unit warning' }),
|
||||
).toBeInTheDocument();
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.findByText('This metric does not have a unit'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('warning tooltip shows metric details link', async () => {
|
||||
it('clicking on warning icon tooltip should open metric details modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTimeSeries({
|
||||
const { container } = renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
yAxisUnit: 'seconds',
|
||||
});
|
||||
|
||||
const alertIcon = screen.getByRole('img', { name: 'no unit warning' });
|
||||
await user.hover(alertIcon);
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
|
||||
expect(await screen.findByText('metric details')).toBeInTheDocument();
|
||||
const metricDetailsLink = await screen.findByText('metric details');
|
||||
user.click(metricDetailsLink);
|
||||
|
||||
waitFor(() =>
|
||||
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows save unit prompt with enabled button when metric has no unit and a unit is selected', async () => {
|
||||
renderTimeSeries({
|
||||
it('shows Save unit button when metric had no unit but one is selected', async () => {
|
||||
const { findByText, getByRole } = renderTimeSeries({
|
||||
metricUnits: [undefined],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
@@ -143,10 +131,38 @@ describe('TimeSeries', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('Set the selected unit as the metric unit?'),
|
||||
await findByText('Save the selected unit for this metric?'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const yesButton = screen.getByRole('button', { name: 'Yes' });
|
||||
const yesButton = getByRole('button', { name: 'Yes' });
|
||||
expect(yesButton).toBeInTheDocument();
|
||||
expect(yesButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('clicking on save unit button shoould upated metric metadata', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderTimeSeries({
|
||||
metricUnits: [''],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
yAxisUnit: 'seconds',
|
||||
showYAxisUnitSelector: true,
|
||||
});
|
||||
|
||||
const yesButton = getByRole('button', { name: /Yes/i });
|
||||
await user.click(yesButton);
|
||||
|
||||
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: 'metric1',
|
||||
},
|
||||
data: expect.objectContaining({ unit: 'seconds' }),
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,14 +139,4 @@ describe('getMetricUnits', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('s');
|
||||
});
|
||||
|
||||
it('should return undefined for metrics with no unit', () => {
|
||||
const result = getMetricUnits([
|
||||
{ ...MOCK_METRIC_METADATA, unit: '' },
|
||||
{ ...MOCK_METRIC_METADATA, unit: '' },
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBeUndefined();
|
||||
expect(result[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { MetricNameSelector } from 'container/QueryBuilder/filters';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -27,7 +27,7 @@ function MetricNameSearch({
|
||||
className="inspect-metrics-input-group metric-name-search"
|
||||
>
|
||||
<Typography.Text>From</Typography.Text>
|
||||
<MetricNameSelector
|
||||
<AggregatorFilter
|
||||
defaultValue={searchText ?? ''}
|
||||
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
|
||||
onSelect={handleSetMetricName}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsService from 'api/generated/services/metrics';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import store from 'store';
|
||||
@@ -24,31 +23,27 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/metrics', () => ({
|
||||
useListMetrics: jest.fn().mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: { data: { metrics: [] } },
|
||||
}),
|
||||
useUpdateMetricMetadata: jest.fn().mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
jest.mock('container/QueryBuilder/filters', () => ({
|
||||
AggregatorFilter: ({ onSelect, onChange, defaultValue }: any): JSX.Element => (
|
||||
<div data-testid="mock-aggregator-filter">
|
||||
<input
|
||||
data-testid="metric-name-input"
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
|
||||
onChange({ key: e.target.value })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-metric-button"
|
||||
onClick={(): void => onSelect({ key: 'test_metric_2' })}
|
||||
>
|
||||
Select Metric
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDebounce', () => ({
|
||||
__esModule: true,
|
||||
default: <T,>(value: T): T => value,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
@@ -128,24 +123,6 @@ describe('QueryBuilder', () => {
|
||||
|
||||
it('should call setCurrentMetricName when metric name is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
(metricsService.useListMetrics as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: {
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
metricName: 'test_metric_2',
|
||||
type: 'Sum',
|
||||
isMonotonic: true,
|
||||
description: '',
|
||||
temporality: 'cumulative',
|
||||
unit: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -160,12 +137,8 @@ describe('QueryBuilder', () => {
|
||||
|
||||
expect(screen.getByText('From')).toBeInTheDocument();
|
||||
|
||||
const input = within(metricNameSearch).getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'test_metric_2' } });
|
||||
|
||||
const options = document.querySelectorAll('.ant-select-item');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
await user.click(options[0] as HTMLElement);
|
||||
const selectButton = screen.getByTestId('select-metric-button');
|
||||
await user.click(selectButton);
|
||||
|
||||
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Button,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Input,
|
||||
Menu,
|
||||
Popover,
|
||||
Tooltip,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
@@ -14,50 +14,148 @@ import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricAttributes } from 'api/generated/services/metrics';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { Check, Copy, Info, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Compass, Copy, Search } from 'lucide-react';
|
||||
|
||||
import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import {
|
||||
AllAttributesEmptyText,
|
||||
AllAttributesValue,
|
||||
} from './AllAttributesValue';
|
||||
import { AllAttributesProps } from './types';
|
||||
AllAttributesEmptyTextProps,
|
||||
AllAttributesProps,
|
||||
AllAttributesValueProps,
|
||||
} from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
|
||||
const ALL_ATTRIBUTES_KEY = 'all-attributes';
|
||||
const COPY_FEEDBACK_DURATION_MS = 1500;
|
||||
|
||||
function AllAttributesEmptyText({
|
||||
isErrorAttributes,
|
||||
refetchAttributes,
|
||||
}: AllAttributesEmptyTextProps): JSX.Element {
|
||||
if (isErrorAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchAttributes}
|
||||
errorMessage="Something went wrong while fetching attributes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Typography.Text>No attributes found</Typography.Text>;
|
||||
}
|
||||
|
||||
export function AllAttributesValue({
|
||||
filterKey,
|
||||
filterValue,
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
}: AllAttributesValueProps): JSX.Element {
|
||||
const [visibleIndex, setVisibleIndex] = useState(5);
|
||||
const [attributePopoverKey, setAttributePopoverKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleShowMore = (): void => {
|
||||
setVisibleIndex(visibleIndex + 5);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = useCallback(
|
||||
(key: string, attribute: string): void => {
|
||||
switch (key) {
|
||||
case 'open-in-explorer':
|
||||
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
|
||||
break;
|
||||
case 'copy-attribute':
|
||||
copyToClipboard(attribute);
|
||||
notifications.success({
|
||||
message: 'Attribute copied!',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setAttributePopoverKey(null);
|
||||
},
|
||||
[
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
filterKey,
|
||||
copyToClipboard,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
|
||||
const attributePopoverContent = useCallback(
|
||||
(attribute: string) => (
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
icon: <Compass size={16} />,
|
||||
label: 'Open in Explorer',
|
||||
key: 'open-in-explorer',
|
||||
},
|
||||
{
|
||||
icon: <Copy size={16} />,
|
||||
label: 'Copy Attribute',
|
||||
key: 'copy-attribute',
|
||||
},
|
||||
]}
|
||||
onClick={(info): void => {
|
||||
handleMenuItemClick(info.key, attribute);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[handleMenuItemClick],
|
||||
);
|
||||
return (
|
||||
<div className="all-attributes-value">
|
||||
{filterValue.slice(0, visibleIndex).map((attribute) => (
|
||||
<Popover
|
||||
key={attribute}
|
||||
content={attributePopoverContent(attribute)}
|
||||
trigger="click"
|
||||
open={attributePopoverKey === `${filterKey}-${attribute}`}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setAttributePopoverKey(null);
|
||||
} else {
|
||||
setAttributePopoverKey(`${filterKey}-${attribute}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button key={attribute} type="text">
|
||||
<Typography.Text>{attribute}</Typography.Text>
|
||||
</Button>
|
||||
</Popover>
|
||||
))}
|
||||
{visibleIndex < filterValue.length && (
|
||||
<Button type="text" onClick={handleShowMore}>
|
||||
Show More
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllAttributes({
|
||||
metricName,
|
||||
metricType,
|
||||
isMonotonic,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
|
||||
const [keyPopoverOpen, setKeyPopoverOpen] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const {
|
||||
data: attributesData,
|
||||
isLoading: isLoadingAttributes,
|
||||
isError: isErrorAttributes,
|
||||
refetch: refetchAttributes,
|
||||
} = useGetMetricAttributes(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
},
|
||||
);
|
||||
} = useGetMetricAttributes({
|
||||
metricName,
|
||||
});
|
||||
|
||||
const attributes = useMemo(() => attributesData?.data.attributes ?? [], [
|
||||
attributesData,
|
||||
@@ -66,15 +164,12 @@ function AllAttributes({
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const goToMetricsExplorerwithAppliedSpaceAggregation = useCallback(
|
||||
(groupBy: string, valueCount?: number) => {
|
||||
const limit = valueCount && valueCount > 250 ? 100 : undefined;
|
||||
(groupBy: string) => {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metricType,
|
||||
undefined,
|
||||
groupBy,
|
||||
limit,
|
||||
isMonotonic,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -84,7 +179,6 @@ function AllAttributes({
|
||||
id: metricName,
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
true,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
@@ -93,19 +187,15 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const goToMetricsExploreWithAppliedAttribute = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metricType,
|
||||
{ key, value },
|
||||
undefined,
|
||||
undefined,
|
||||
isMonotonic,
|
||||
);
|
||||
const compositeQuery = getMetricDetailsQuery(metricName, metricType, {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
@@ -114,7 +204,6 @@ function AllAttributes({
|
||||
id: metricName,
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
true,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
@@ -124,29 +213,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeValue]: value,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const handleKeyMenuItemClick = useCallback(
|
||||
(menuKey: string, attributeKey: string, valueCount?: number): void => {
|
||||
switch (menuKey) {
|
||||
case 'open-in-explorer':
|
||||
goToMetricsExplorerwithAppliedSpaceAggregation(attributeKey, valueCount);
|
||||
break;
|
||||
case 'copy-key':
|
||||
copyToClipboard(attributeKey);
|
||||
setCopiedKey(attributeKey);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => {
|
||||
setCopiedKey(null);
|
||||
}, COPY_FEEDBACK_DURATION_MS);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setKeyPopoverOpen(null);
|
||||
},
|
||||
[goToMetricsExplorerwithAppliedSpaceAggregation, copyToClipboard],
|
||||
[metricName, metricType, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const filteredAttributes = useMemo(
|
||||
@@ -187,57 +254,21 @@ function AllAttributes({
|
||||
width: 50,
|
||||
align: 'left',
|
||||
className: 'metric-metadata-key',
|
||||
render: (field: { label: string; contribution: number }): JSX.Element => {
|
||||
const isCopied = copiedKey === field.label;
|
||||
return (
|
||||
<div className="all-attributes-key">
|
||||
<Popover
|
||||
content={
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
icon: <SquareArrowOutUpRight size={14} />,
|
||||
label: 'Open in Metric Explorer',
|
||||
key: 'open-in-explorer',
|
||||
},
|
||||
{
|
||||
icon: <Copy size={14} />,
|
||||
label: 'Copy Key',
|
||||
key: 'copy-key',
|
||||
},
|
||||
]}
|
||||
onClick={(info): void => {
|
||||
handleKeyMenuItemClick(info.key, field.label, field.contribution);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
placement="right"
|
||||
overlayClassName="metric-details-popover attribute-key-popover-overlay"
|
||||
open={keyPopoverOpen === field.label}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setKeyPopoverOpen(null);
|
||||
} else {
|
||||
setKeyPopoverOpen(field.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="text">
|
||||
<Typography.Text>{field.label}</Typography.Text>
|
||||
</Button>
|
||||
</Popover>
|
||||
{isCopied && (
|
||||
<span className="copy-feedback">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text className="all-attributes-contribution">
|
||||
{field.contribution}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
render: (field: { label: string; contribution: number }): JSX.Element => (
|
||||
<div className="all-attributes-key">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void =>
|
||||
goToMetricsExplorerwithAppliedSpaceAggregation(field.label)
|
||||
}
|
||||
>
|
||||
<Typography.Text>{field.label}</Typography.Text>
|
||||
</Button>
|
||||
<Typography.Text className="all-attributes-contribution">
|
||||
{field.contribution}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Value',
|
||||
@@ -260,9 +291,7 @@ function AllAttributes({
|
||||
],
|
||||
[
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
handleKeyMenuItemClick,
|
||||
keyPopoverOpen,
|
||||
copiedKey,
|
||||
goToMetricsExplorerwithAppliedSpaceAggregation,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -271,12 +300,7 @@ function AllAttributes({
|
||||
{
|
||||
label: (
|
||||
<div className="metrics-accordion-header">
|
||||
<div className="all-attributes-header-title">
|
||||
<Typography.Text>All Attributes</Typography.Text>
|
||||
<Tooltip title="Showing attributes for the selected time range">
|
||||
<Info size={14} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text>All Attributes</Typography.Text>
|
||||
<Input
|
||||
className="all-attributes-search-input"
|
||||
placeholder="Search"
|
||||
@@ -305,9 +329,7 @@ function AllAttributes({
|
||||
className="metrics-accordion-content all-attributes-content"
|
||||
scroll={{ y: 600 }}
|
||||
locale={{
|
||||
emptyText: isLoadingAttributes ? (
|
||||
' '
|
||||
) : (
|
||||
emptyText: (
|
||||
<AllAttributesEmptyText
|
||||
isErrorAttributes={isErrorAttributes}
|
||||
refetchAttributes={refetchAttributes}
|
||||
@@ -328,6 +350,14 @@ function AllAttributes({
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoadingAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Input, Menu, Popover, Tooltip, Typography } from 'antd';
|
||||
import { Check, Copy, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import { AllAttributesEmptyTextProps, AllAttributesValueProps } from './types';
|
||||
|
||||
const INITIAL_VISIBLE_COUNT = 5;
|
||||
const COPY_FEEDBACK_DURATION_MS = 1500;
|
||||
|
||||
export function AllAttributesEmptyText({
|
||||
isErrorAttributes,
|
||||
refetchAttributes,
|
||||
}: AllAttributesEmptyTextProps): JSX.Element {
|
||||
if (isErrorAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchAttributes}
|
||||
errorMessage="Something went wrong while fetching attributes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Typography.Text>No attributes found</Typography.Text>;
|
||||
}
|
||||
|
||||
export function AllAttributesValue({
|
||||
filterKey,
|
||||
filterValue,
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
}: AllAttributesValueProps): JSX.Element {
|
||||
const [attributePopoverKey, setAttributePopoverKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [allValuesOpen, setAllValuesOpen] = useState(false);
|
||||
const [allValuesSearch, setAllValuesSearch] = useState('');
|
||||
const [copiedValue, setCopiedValue] = useState<string | null>(null);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopyWithFeedback = useCallback(
|
||||
(value: string): void => {
|
||||
copyToClipboard(value);
|
||||
setCopiedValue(value);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => {
|
||||
setCopiedValue(null);
|
||||
}, COPY_FEEDBACK_DURATION_MS);
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const handleMenuItemClick = useCallback(
|
||||
(key: string, attribute: string): void => {
|
||||
switch (key) {
|
||||
case 'open-in-explorer':
|
||||
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
|
||||
break;
|
||||
case 'copy-value':
|
||||
handleCopyWithFeedback(attribute);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setAttributePopoverKey(null);
|
||||
},
|
||||
[goToMetricsExploreWithAppliedAttribute, filterKey, handleCopyWithFeedback],
|
||||
);
|
||||
|
||||
const attributePopoverContent = useCallback(
|
||||
(attribute: string) => (
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
icon: <SquareArrowOutUpRight size={14} />,
|
||||
label: 'Open in Metric Explorer',
|
||||
key: 'open-in-explorer',
|
||||
},
|
||||
{
|
||||
icon: <Copy size={14} />,
|
||||
label: 'Copy Value',
|
||||
key: 'copy-value',
|
||||
},
|
||||
]}
|
||||
onClick={(info): void => {
|
||||
handleMenuItemClick(info.key, attribute);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[handleMenuItemClick],
|
||||
);
|
||||
|
||||
const filteredAllValues = useMemo(
|
||||
() =>
|
||||
allValuesSearch
|
||||
? filterValue.filter((v) =>
|
||||
v.toLowerCase().includes(allValuesSearch.toLowerCase()),
|
||||
)
|
||||
: filterValue,
|
||||
[filterValue, allValuesSearch],
|
||||
);
|
||||
|
||||
const allValuesPopoverContent = (
|
||||
<div className="all-values-popover">
|
||||
<Input
|
||||
placeholder="Search values"
|
||||
size="small"
|
||||
prefix={<Search size={12} />}
|
||||
value={allValuesSearch}
|
||||
onChange={(e): void => setAllValuesSearch(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<div className="all-values-list">
|
||||
{allValuesOpen &&
|
||||
filteredAllValues.map((attribute) => {
|
||||
const isCopied = copiedValue === attribute;
|
||||
return (
|
||||
<div key={attribute} className="all-values-item">
|
||||
<Typography.Text ellipsis className="all-values-item-text">
|
||||
{attribute}
|
||||
</Typography.Text>
|
||||
<div className="all-values-item-actions">
|
||||
<Tooltip title={isCopied ? 'Copied!' : 'Copy value'}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={isCopied ? 'copy-success' : ''}
|
||||
icon={isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
onClick={(): void => {
|
||||
handleCopyWithFeedback(attribute);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open in Metric Explorer">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SquareArrowOutUpRight size={12} />}
|
||||
onClick={(): void => {
|
||||
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
|
||||
setAllValuesOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allValuesOpen && filteredAllValues.length === 0 && (
|
||||
<Typography.Text type="secondary" className="all-values-empty">
|
||||
No values found
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="all-attributes-value">
|
||||
{filterValue.slice(0, INITIAL_VISIBLE_COUNT).map((attribute) => {
|
||||
const isCopied = copiedValue === attribute;
|
||||
return (
|
||||
<div key={attribute} className="all-attributes-value-item">
|
||||
<Popover
|
||||
content={attributePopoverContent(attribute)}
|
||||
trigger="click"
|
||||
overlayClassName="metric-details-popover attribute-value-popover-overlay"
|
||||
open={attributePopoverKey === `${filterKey}-${attribute}`}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setAttributePopoverKey(null);
|
||||
} else {
|
||||
setAttributePopoverKey(`${filterKey}-${attribute}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="text">
|
||||
<Typography.Text>{attribute}</Typography.Text>
|
||||
</Button>
|
||||
</Popover>
|
||||
{isCopied && (
|
||||
<span className="copy-feedback">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filterValue.length > INITIAL_VISIBLE_COUNT && (
|
||||
<Popover
|
||||
content={allValuesPopoverContent}
|
||||
trigger="click"
|
||||
open={allValuesOpen}
|
||||
onOpenChange={(open): void => {
|
||||
setAllValuesOpen(open);
|
||||
if (!open) {
|
||||
setAllValuesSearch('');
|
||||
setCopiedValue(null);
|
||||
}
|
||||
}}
|
||||
overlayClassName="metric-details-popover all-values-popover-overlay"
|
||||
>
|
||||
<Button type="text" className="all-values-button">
|
||||
All values ({filterValue.length})
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from 'api/generated/services/metrics';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { Bell, Grid } from 'lucide-react';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
@@ -18,6 +20,7 @@ import { DashboardsAndAlertsPopoverProps } from './types';
|
||||
function DashboardsAndAlertsPopover({
|
||||
metricName,
|
||||
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const params = useUrlQuery();
|
||||
|
||||
const {
|
||||
@@ -72,7 +75,7 @@ function DashboardsAndAlertsPopover({
|
||||
key={alert.alertId}
|
||||
onClick={(): void => {
|
||||
params.set(QueryParams.ruleId, alert.alertId);
|
||||
window.open(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, '_blank');
|
||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
@@ -92,11 +95,10 @@ function DashboardsAndAlertsPopover({
|
||||
<Typography.Link
|
||||
key={dashboard.dashboardId}
|
||||
onClick={(): void => {
|
||||
window.open(
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, {
|
||||
dashboardId: dashboard.dashboardId,
|
||||
}),
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
@@ -107,7 +109,7 @@ function DashboardsAndAlertsPopover({
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}, [dashboards]);
|
||||
}, [dashboards, safeNavigate]);
|
||||
|
||||
if (isLoadingAlerts || isLoadingDashboards) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Spin, Tooltip, Typography } from 'antd';
|
||||
import { Button, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { useGetMetricHighlights } from 'api/generated/services/metrics';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
@@ -39,6 +39,17 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
metricHighlights?.lastReceived,
|
||||
);
|
||||
|
||||
if (isLoadingMetricHighlights) {
|
||||
return (
|
||||
<div
|
||||
className="metric-details-content-grid"
|
||||
data-testid="metric-highlights-loading-state"
|
||||
>
|
||||
<Skeleton title={false} paragraph={{ rows: 2 }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorMetricHighlights) {
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
@@ -78,41 +89,32 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
{isLoadingMetricHighlights ? (
|
||||
<div className="metric-highlights-loading-inline">
|
||||
<Spin size="small" />
|
||||
<Typography.Text type="secondary">Loading metric stats</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-data-points"
|
||||
>
|
||||
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-time-series-total"
|
||||
>
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-last-received"
|
||||
>
|
||||
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-data-points"
|
||||
>
|
||||
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-time-series-total"
|
||||
>
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-last-received"
|
||||
>
|
||||
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button, Collapse, Input, Select, Spin, Typography } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
@@ -334,7 +334,7 @@ function Metadata({
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={isUpdatingMetricsMetadata || isLoadingMetricMetadata}
|
||||
disabled={isUpdatingMetricsMetadata}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<Typography.Text>Edit</Typography.Text>
|
||||
@@ -345,7 +345,6 @@ function Metadata({
|
||||
isEditing,
|
||||
isErrorMetricMetadata,
|
||||
isUpdatingMetricsMetadata,
|
||||
isLoadingMetricMetadata,
|
||||
cancelEdit,
|
||||
handleSave,
|
||||
]);
|
||||
@@ -360,11 +359,7 @@ function Metadata({
|
||||
</div>
|
||||
),
|
||||
key: 'metric-metadata',
|
||||
children: isLoadingMetricMetadata ? (
|
||||
<div className="metrics-accordion-loading-state">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : isErrorMetricMetadata ? (
|
||||
children: isErrorMetricMetadata ? (
|
||||
<div className="metric-metadata-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchMetricMetadata}
|
||||
@@ -386,13 +381,20 @@ function Metadata({
|
||||
[
|
||||
actionButton,
|
||||
columns,
|
||||
isLoadingMetricMetadata,
|
||||
isErrorMetricMetadata,
|
||||
refetchMetricMetadata,
|
||||
tableData,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoadingMetricMetadata) {
|
||||
return (
|
||||
<div className="metrics-metadata-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
|
||||
@@ -52,13 +52,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-highlights-loading-inline {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-highlights-error-state {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -127,11 +120,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-accordion-loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
.metrics-metadata-skeleton-container {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
.all-attributes-skeleton-container {
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.metrics-accordion {
|
||||
@@ -159,18 +153,6 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
|
||||
.all-attributes-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.lucide-info {
|
||||
cursor: pointer;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-robin-400);
|
||||
@@ -204,7 +186,6 @@
|
||||
.all-attributes-key {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.ant-btn {
|
||||
.ant-typography:first-child {
|
||||
font-family: 'Geist Mono';
|
||||
@@ -212,15 +193,17 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.copy-feedback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--bg-forest-500);
|
||||
animation: fade-in-out 1.5s ease-in-out;
|
||||
}
|
||||
.all-attributes-contribution {
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: rgba(171, 189, 255, 0.1);
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,18 +228,6 @@
|
||||
background: var(--bg-slate-300);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.all-attributes-value-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.copy-feedback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--bg-forest-500);
|
||||
animation: fade-in-out 1.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
.ant-btn {
|
||||
text-align: left;
|
||||
width: fit-content;
|
||||
@@ -288,8 +259,10 @@
|
||||
}
|
||||
|
||||
.metric-metadata-key {
|
||||
cursor: pointer;
|
||||
padding-left: 10px;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
.field-renderer-container {
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -410,8 +383,9 @@
|
||||
}
|
||||
|
||||
.all-attributes-key {
|
||||
.all-attributes-contribution {
|
||||
color: var(--bg-slate-400);
|
||||
.ant-typography:last-child {
|
||||
color: var(--bg-vanilla-200);
|
||||
background-color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,146 +448,3 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attribute-key-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
|
||||
.ant-menu-item {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-popover {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
|
||||
.all-values-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.all-values-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.all-values-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.copy-success {
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-empty {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-button {
|
||||
color: var(--bg-robin-400) !important;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.attribute-key-popover-overlay,
|
||||
.all-values-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-popover {
|
||||
.all-values-item {
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-out {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Drawer, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricMetadata } from 'api/generated/services/metrics';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, Crosshair, X } from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
@@ -33,9 +29,6 @@ function MetricDetails({
|
||||
}: MetricDetailsProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const {
|
||||
data: metricMetadataResponse,
|
||||
@@ -80,14 +73,7 @@ function MetricDetails({
|
||||
|
||||
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
|
||||
if (metricName) {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metadata?.type,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
metadata?.isMonotonic,
|
||||
);
|
||||
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
@@ -96,7 +82,6 @@ function MetricDetails({
|
||||
id: metricName,
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
true,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
@@ -104,12 +89,7 @@ function MetricDetails({
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
metricName,
|
||||
handleExplorerTabChange,
|
||||
metadata?.type,
|
||||
metadata?.isMonotonic,
|
||||
]);
|
||||
}, [metricName, handleExplorerTabChange, metadata?.type]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.ModalOpened, {
|
||||
@@ -120,21 +100,6 @@ function MetricDetails({
|
||||
const isActionButtonDisabled =
|
||||
!metricName || isLoadingMetricMetadata || isErrorMetricMetadata;
|
||||
|
||||
const handleDrawerClose = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
if ('key' in e && e.key === 'Escape') {
|
||||
const openPopover = document.querySelector(
|
||||
'.metric-details-popover:not(.ant-popover-hidden)',
|
||||
);
|
||||
if (openPopover) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
@@ -172,7 +137,7 @@ function MetricDetails({
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
onClose={handleDrawerClose}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
@@ -192,13 +157,7 @@ function MetricDetails({
|
||||
isLoadingMetricMetadata={isLoadingMetricMetadata}
|
||||
refetchMetricMetadata={refetchMetricMetadata}
|
||||
/>
|
||||
<AllAttributes
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
isMonotonic={metadata?.isMonotonic}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
<AllAttributes metricName={metricName} metricType={metadata?.type} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as reactUseHooks from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import ROUTES from '../../../../constants/routes';
|
||||
import AllAttributes from '../AllAttributes';
|
||||
import { AllAttributesValue } from '../AllAttributesValue';
|
||||
import AllAttributes, { AllAttributesValue } from '../AllAttributes';
|
||||
import { getMockMetricAttributesData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -14,6 +15,17 @@ jest.mock('react-router-dom', () => ({
|
||||
pathname: `${ROUTES.METRICS_EXPLORER}`,
|
||||
}),
|
||||
}));
|
||||
const mockHandleExplorerTabChange = jest.fn();
|
||||
jest
|
||||
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
|
||||
.mockReturnValue({
|
||||
handleExplorerTabChange: mockHandleExplorerTabChange,
|
||||
});
|
||||
|
||||
const mockUseCopyToClipboard = jest.fn();
|
||||
jest
|
||||
.spyOn(reactUseHooks, 'useCopyToClipboard')
|
||||
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
|
||||
|
||||
const useGetMetricAttributesMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
@@ -22,13 +34,12 @@ const useGetMetricAttributesMock = jest.spyOn(
|
||||
|
||||
describe('AllAttributes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders attribute keys, values, and value counts from API data', () => {
|
||||
it('renders attributes section with title', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
@@ -36,13 +47,39 @@ describe('AllAttributes', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('All Attributes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all attribute keys and values', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check attribute keys are rendered
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
expect(screen.getByText('attribute2')).toBeInTheDocument();
|
||||
|
||||
// Check attribute values are rendered
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders value counts correctly', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument(); // For attribute1
|
||||
expect(screen.getByText('1')).toBeInTheDocument(); // For attribute2
|
||||
});
|
||||
|
||||
it('handles empty attributes array', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData({
|
||||
@@ -63,7 +100,7 @@ describe('AllAttributes', () => {
|
||||
expect(screen.getByText('No attributes found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on an attribute key shows popover with Open in Metric Explorer option', async () => {
|
||||
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
@@ -71,8 +108,7 @@ describe('AllAttributes', () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('attribute1'));
|
||||
expect(screen.getByText('Open in Metric Explorer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copy Key')).toBeInTheDocument();
|
||||
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters attributes based on search input', async () => {
|
||||
@@ -87,66 +123,26 @@ describe('AllAttributes', () => {
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when attribute fetching fails', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(
|
||||
{
|
||||
data: {
|
||||
attributes: [],
|
||||
totalKeys: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isError: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Something went wrong while fetching attributes'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show misleading empty text while loading', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(
|
||||
{
|
||||
data: {
|
||||
attributes: [],
|
||||
totalKeys: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isLoading: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('No attributes found')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllAttributesValue', () => {
|
||||
const mockGoToMetricsExploreWithAppliedAttribute = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
it('renders all attribute values', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows All values button when there are more than 5 values', () => {
|
||||
it('loads more attributes when show more button is clicked', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -157,59 +153,58 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('value6')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('All values (6)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('All values popover shows values beyond the initial 5', async () => {
|
||||
const values = [
|
||||
'value1',
|
||||
'value2',
|
||||
'value3',
|
||||
'value4',
|
||||
'value5',
|
||||
'value6',
|
||||
'value7',
|
||||
];
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={values}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('All values (7)'));
|
||||
|
||||
await userEvent.click(screen.getByText('Show More'));
|
||||
expect(screen.getByText('value6')).toBeInTheDocument();
|
||||
expect(screen.getByText('value7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('All values popover search filters the value list', async () => {
|
||||
const values = [
|
||||
'alpha',
|
||||
'bravo',
|
||||
'charlie',
|
||||
'delta',
|
||||
'echo',
|
||||
'fig-special',
|
||||
'golf-target',
|
||||
];
|
||||
it('does not render show more button when there are no more attributes to show', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={values}
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('All values (7)'));
|
||||
await userEvent.type(screen.getByPlaceholderText('Search values'), 'golf');
|
||||
it('copy button should copy the attribute value to the clipboard', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Copy Attribute'));
|
||||
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
|
||||
});
|
||||
|
||||
expect(screen.getByText('golf-target')).toBeInTheDocument();
|
||||
expect(screen.queryByText('fig-special')).not.toBeInTheDocument();
|
||||
it('explorer button should go to metrics explore with the attribute filter applied', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
|
||||
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Open in Explorer'));
|
||||
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
|
||||
'attribute1',
|
||||
'value1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
MOCK_METRIC_NAME,
|
||||
} from './testUtlls';
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', { value: mockWindowOpen });
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
set: mockSetQuery,
|
||||
@@ -39,7 +43,6 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
|
||||
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
it('renders the popover correctly with multiple dashboards and alerts', () => {
|
||||
@@ -137,10 +140,9 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
// Click on the first dashboard
|
||||
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
|
||||
|
||||
// Should open dashboard in new tab
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
// Should navigate to the dashboard
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -156,12 +158,11 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
// Click on the first alert rule
|
||||
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
|
||||
|
||||
// Should open alert in new tab
|
||||
// Should navigate to the alert rule
|
||||
expect(mockSetQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleId,
|
||||
MOCK_ALERT_1.alertId,
|
||||
);
|
||||
expect(mockWindowOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders unique dashboards even when there are duplicates', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Highlights', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show labels and loading text but not stale data values while loading', () => {
|
||||
it('should render loading state when data is loading', () => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(
|
||||
getMockMetricHighlightsData(
|
||||
{},
|
||||
@@ -60,19 +60,8 @@ describe('Highlights', () => {
|
||||
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(screen.getByText('SAMPLES')).toBeInTheDocument();
|
||||
expect(screen.getByText('TIME SERIES')).toBeInTheDocument();
|
||||
expect(screen.getByText('LAST RECEIVED')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading metric stats')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('metric-highlights-data-points'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('metric-highlights-time-series-total'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('metric-highlights-last-received'),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByTestId('metric-highlights-loading-state'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,22 +324,6 @@ describe('Metadata', () => {
|
||||
expect(editButton2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show section header with disabled edit while loading', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={null}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Metadata')).toBeInTheDocument();
|
||||
const editButton = screen.getByText('Edit').closest('button');
|
||||
expect(editButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not allow editing of unit if it is already set', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
|
||||
@@ -24,13 +24,6 @@ jest.mock('react-router-dom', () => ({
|
||||
pathname: `${ROUTES.METRICS_EXPLORER}`,
|
||||
}),
|
||||
}));
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({
|
||||
maxTime: 1700000000000000000,
|
||||
minTime: 1699900000000000000,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
determineIsMonotonic,
|
||||
@@ -140,7 +139,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.SUM,
|
||||
MetrictypesTypeDTO.sum,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
@@ -157,7 +156,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
MetrictypesTypeDTO.gauge,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
@@ -174,7 +173,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
MetrictypesTypeDTO.summary,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -191,7 +190,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
MetrictypesTypeDTO.histogram,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -208,7 +207,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
MetrictypesTypeDTO.exponentialhistogram,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
|
||||
@@ -34,9 +34,6 @@ export interface MetadataProps {
|
||||
export interface AllAttributesProps {
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
isMonotonic?: boolean;
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
export interface AllAttributesValueProps {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
|
||||
import { initialQueriesMap, toAttributeType } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -87,26 +87,15 @@ export function getMetricDetailsQuery(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
filter?: { key: string; value: string },
|
||||
groupBy?: string,
|
||||
limit?: number,
|
||||
isMonotonic?: boolean,
|
||||
): Query {
|
||||
let timeAggregation;
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
const isNonMonotonicSum =
|
||||
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
|
||||
|
||||
switch (metricType) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
if (isNonMonotonicSum) {
|
||||
timeAggregation = 'avg';
|
||||
spaceAggregation = 'avg';
|
||||
aggregateOperator = 'avg';
|
||||
} else {
|
||||
timeAggregation = 'rate';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'rate';
|
||||
}
|
||||
timeAggregation = 'rate';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'rate';
|
||||
break;
|
||||
case MetrictypesTypeDTO.gauge:
|
||||
timeAggregation = 'avg';
|
||||
@@ -131,8 +120,6 @@ export function getMetricDetailsQuery(
|
||||
break;
|
||||
}
|
||||
|
||||
const attributeType = toAttributeType(metricType, isMonotonic);
|
||||
|
||||
return {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
@@ -141,8 +128,8 @@ export function getMetricDetailsQuery(
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
key: metricName,
|
||||
type: attributeType,
|
||||
id: `${metricName}----${attributeType}---string--`,
|
||||
type: metricType ?? '',
|
||||
id: `${metricName}----${metricType}---string--`,
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
aggregations: [
|
||||
@@ -183,7 +170,6 @@ export function getMetricDetailsQuery(
|
||||
},
|
||||
]
|
||||
: [],
|
||||
...(limit ? { limit } : {}),
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { Info } from 'lucide-react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricsSearchProps } from './types';
|
||||
@@ -24,17 +26,15 @@ function MetricsSearch({
|
||||
onChange(currentQueryFilterExpression);
|
||||
}, [currentQueryFilterExpression, onChange]);
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(expression: string): void => {
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
onChange(expression);
|
||||
},
|
||||
[setCurrentQueryFilterExpression, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="metrics-search-container">
|
||||
<div data-testid="qb-search-container" className="qb-search-container">
|
||||
<Tooltip
|
||||
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
<QuerySearch
|
||||
onChange={handleOnChange}
|
||||
dataSource={DataSource.METRICS}
|
||||
@@ -45,9 +45,8 @@ function MetricsSearch({
|
||||
expression: currentQueryFilterExpression,
|
||||
},
|
||||
}}
|
||||
onRun={handleRunQuery}
|
||||
onRun={handleOnChange}
|
||||
showFilterSuggestionsWithoutMetric
|
||||
placeholder="Try metric_name CONTAINS 'http.server' to view all HTTP Server metrics being sent"
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
.metrics-search-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.metrics-search-options {
|
||||
@@ -51,6 +51,10 @@
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
.lucide-info {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.query-builder-search-container {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -62,6 +66,8 @@
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
convertExpressionToFilters,
|
||||
convertFiltersToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -60,13 +61,10 @@ function Summary(): JSX.Element {
|
||||
heatmapView,
|
||||
setHeatmapView,
|
||||
] = useState<MetricsexplorertypesTreemapModeDTO>(
|
||||
MetricsexplorertypesTreemapModeDTO.samples,
|
||||
MetricsexplorertypesTreemapModeDTO.timeseries,
|
||||
);
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
|
||||
|
||||
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
|
||||
currentQuery,
|
||||
]);
|
||||
@@ -91,15 +89,6 @@ function Summary(): JSX.Element {
|
||||
setCurrentQueryFilterExpression,
|
||||
] = useState<string>(query?.filter?.expression || '');
|
||||
|
||||
const [appliedFilterExpression, setAppliedFilterExpression] = useState(
|
||||
query?.filter?.expression || '',
|
||||
);
|
||||
|
||||
const queryFilterExpression = useMemo(
|
||||
() => ({ expression: appliedFilterExpression }),
|
||||
[appliedFilterExpression],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.TabChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
@@ -111,6 +100,11 @@ function Summary(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const queryFilterExpression = useMemo(() => {
|
||||
const filters = query.filters || { items: [], op: 'AND' };
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [query.filters]);
|
||||
|
||||
const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => {
|
||||
return {
|
||||
start: convertNanoToMilliseconds(minTime),
|
||||
@@ -193,7 +187,6 @@ function Summary(): JSX.Element {
|
||||
},
|
||||
});
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
setAppliedFilterExpression(expression);
|
||||
setCurrentPage(1);
|
||||
if (expression) {
|
||||
logEvent(MetricsExplorerEvents.FilterApplied, {
|
||||
@@ -312,9 +305,7 @@ function Summary(): JSX.Element {
|
||||
/>
|
||||
{showFullScreenLoading ? (
|
||||
<MetricsLoading />
|
||||
) : isMetricsListDataEmpty &&
|
||||
isMetricsTreeMapDataEmpty &&
|
||||
!appliedFilterExpression ? (
|
||||
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
|
||||
<NoLogs dataSource={DataSource.METRICS} />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
AggregatorFilter,
|
||||
GroupByFilter,
|
||||
HavingFilter,
|
||||
MetricNameSelector,
|
||||
OperatorsSelect,
|
||||
OrderByFilter,
|
||||
ReduceToFilter,
|
||||
@@ -404,7 +403,7 @@ export const Query = memo(function Query({
|
||||
)}
|
||||
|
||||
<Col flex="auto">
|
||||
<MetricNameSelector
|
||||
<AggregatorFilter
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
/>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.metric-name-selector {
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--bg-slate-200);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -1,887 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
MetricsexplorertypesListMetricDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricNameSelector } from './MetricNameSelector';
|
||||
|
||||
const mockUseListMetrics = jest.fn();
|
||||
jest.mock('api/generated/services/metrics', () => ({
|
||||
useListMetrics: (...args: unknown[]): ReturnType<typeof mockUseListMetrics> =>
|
||||
mockUseListMetrics(...args),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDebounce', () => ({
|
||||
__esModule: true,
|
||||
default: <T,>(value: T): T => value,
|
||||
}));
|
||||
|
||||
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
// Ref lets StatefulMetricQueryHarness wire handleSetQueryData to real state,
|
||||
// while other tests keep the default no-op mock.
|
||||
const handleSetQueryDataRef: {
|
||||
current: (index: number, query: IBuilderQuery) => void;
|
||||
} = {
|
||||
current: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): Record<string, unknown> => ({
|
||||
handleSetQueryData: (index: number, query: IBuilderQuery): void =>
|
||||
handleSetQueryDataRef.current(index, query),
|
||||
handleSetTraceOperatorData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
panelType: 'TIME_SERIES',
|
||||
initialDataSource: DataSource.METRICS,
|
||||
currentQuery: {
|
||||
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
|
||||
queryType: 'builder',
|
||||
},
|
||||
setLastUsedQuery: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeMetric(
|
||||
overrides: Partial<MetricsexplorertypesListMetricDTO> = {},
|
||||
): MetricsexplorertypesListMetricDTO {
|
||||
return {
|
||||
metricName: 'http_requests_total',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
description: '',
|
||||
temporality: 'cumulative' as never,
|
||||
unit: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
|
||||
return {
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', type: '', dataType: DataTypes.Float64 },
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
...overrides,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
function returnMetrics(
|
||||
metrics: MetricsexplorertypesListMetricDTO[],
|
||||
overrides: Record<string, unknown> = {},
|
||||
): void {
|
||||
mockUseListMetrics.mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: { data: { metrics } },
|
||||
queryKey: ['/api/v2/metrics'],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// snippet so tests can assert on them.
|
||||
function MetricQueryHarness({ query }: { query: IBuilderQuery }): JSX.Element {
|
||||
const {
|
||||
handleChangeAggregatorAttribute,
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
} = useQueryOperations({
|
||||
query,
|
||||
index: 0,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MetricNameSelector
|
||||
query={query}
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
/>
|
||||
<ul data-testid="time-agg-options">
|
||||
{operators.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul data-testid="space-agg-options">
|
||||
{spaceAggregationOptions.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptionLabels(testId: string): string[] {
|
||||
const list = screen.getByTestId(testId);
|
||||
const items = within(list).queryAllByRole('listitem');
|
||||
return items.map((el) => el.textContent || '');
|
||||
}
|
||||
|
||||
describe('MetricNameSelector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('shows metric names from API as dropdown options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({ metricName: 'http_requests_total' }),
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage_percent',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'h' } });
|
||||
|
||||
expect(
|
||||
screen.getAllByText('http_requests_total').length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
screen.getAllByText('cpu_usage_percent').length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('retains typed metric name in input after blur', () => {
|
||||
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
|
||||
|
||||
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'http_requests_total' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(input).toHaveValue('http_requests_total');
|
||||
});
|
||||
|
||||
it('shows error message when API request fails', () => {
|
||||
mockUseListMetrics.mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
data: undefined,
|
||||
queryKey: ['/api/v2/metrics'],
|
||||
});
|
||||
|
||||
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: 'test' } });
|
||||
|
||||
expect(screen.getByText('Failed to load metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading spinner while fetching metrics', () => {
|
||||
mockUseListMetrics.mockReturnValue({
|
||||
isFetching: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
queryKey: ['/api/v2/metrics'],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'test' } });
|
||||
|
||||
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selecting a metric type updates the aggregation options', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Sum metric shows Rate/Increase time options and Sum/Avg/Min/Max space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'http_requests_total',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'http_requests_total' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual(['Rate', 'Increase']);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Gauge metric shows Latest/Sum/Avg/Min/Max/Count/Count Distinct time options and Sum/Avg/Min/Max space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage_percent',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'cpu_usage_percent' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Latest',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'Count',
|
||||
'Count Distinct',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic Sum metric is treated as Gauge', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'active_connections',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'active_connections' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Latest',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'Count',
|
||||
'Count Distinct',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Histogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'request_duration_seconds',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'request_duration_seconds' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'P50',
|
||||
'P75',
|
||||
'P90',
|
||||
'P95',
|
||||
'P99',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ExponentialHistogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'request_duration_exp',
|
||||
type: MetrictypesTypeDTO.exponentialhistogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'request_duration_exp' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'P50',
|
||||
'P75',
|
||||
'P90',
|
||||
'P95',
|
||||
'P99',
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown metric (typed name not in API results) shows all time and space options', () => {
|
||||
returnMetrics([makeMetric({ metricName: 'known_metric' })]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'unknown_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Max',
|
||||
'Min',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Count',
|
||||
'Rate',
|
||||
'Increase',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'P50',
|
||||
'P75',
|
||||
'P90',
|
||||
'P95',
|
||||
'P99',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// these tests require the previous state, so we setup it to
|
||||
// tracks previousMetricInfo across metric selections
|
||||
function StatefulMetricQueryHarness({
|
||||
initialQuery,
|
||||
}: {
|
||||
initialQuery: IBuilderQuery;
|
||||
}): JSX.Element {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
|
||||
useEffect(() => {
|
||||
handleSetQueryDataRef.current = (
|
||||
_index: number,
|
||||
newQuery: IBuilderQuery,
|
||||
): void => {
|
||||
setQuery(newQuery);
|
||||
};
|
||||
return (): void => {
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
handleChangeAggregatorAttribute,
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
} = useQueryOperations({
|
||||
query,
|
||||
index: 0,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
});
|
||||
|
||||
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MetricNameSelector
|
||||
query={query}
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
/>
|
||||
<ul data-testid="time-agg-options">
|
||||
{operators.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul data-testid="space-agg-options">
|
||||
{spaceAggregationOptions.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div data-testid="selected-time-agg">
|
||||
{currentAggregation?.timeAggregation || ''}
|
||||
</div>
|
||||
<div data-testid="selected-space-agg">
|
||||
{currentAggregation?.spaceAggregation || ''}
|
||||
</div>
|
||||
<div data-testid="selected-metric-name">
|
||||
{currentAggregation?.metricName || ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('switching between metrics of the same type preserves aggregation settings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Sum: preserves non-default increase/avg when switching to another Sum metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'metric_a',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'metric_b',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'metric_a',
|
||||
type: ATTRIBUTE_TYPES.SUM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'increase',
|
||||
spaceAggregation: 'avg',
|
||||
metricName: 'metric_a',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'metric_b' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'metric_b',
|
||||
);
|
||||
});
|
||||
|
||||
it('Gauge: preserves non-default min/max when switching to another Gauge metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'mem_usage',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'cpu_usage',
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'max',
|
||||
metricName: 'cpu_usage',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'mem_usage' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'mem_usage',
|
||||
);
|
||||
});
|
||||
|
||||
it('Histogram: preserves non-default p99 when switching to another Histogram metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'req_duration',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'db_latency',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'req_duration',
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p99',
|
||||
metricName: 'req_duration',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'db_latency' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'db_latency',
|
||||
);
|
||||
});
|
||||
|
||||
it('ExponentialHistogram: preserves non-default p75 when switching to another ExponentialHistogram metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'exp_hist_a',
|
||||
type: MetrictypesTypeDTO.exponentialhistogram,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'exp_hist_b',
|
||||
type: MetrictypesTypeDTO.exponentialhistogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'exp_hist_a',
|
||||
type: ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p75',
|
||||
metricName: 'exp_hist_a',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'exp_hist_b' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'exp_hist_b',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching to a different metric type resets aggregation to new defaults', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Sum to Gauge: resets from increase/avg to the Gauge defaults avg/avg', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'sum_metric',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'gauge_metric',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'sum_metric',
|
||||
type: ATTRIBUTE_TYPES.SUM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'increase',
|
||||
spaceAggregation: 'avg',
|
||||
metricName: 'sum_metric',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'gauge_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'gauge_metric',
|
||||
);
|
||||
});
|
||||
|
||||
it('Gauge to Histogram: resets from min/max to the Histogram defaults (no time, p90 space)', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'gauge_metric',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'hist_metric',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'gauge_metric',
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'max',
|
||||
metricName: 'gauge_metric',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'hist_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p90');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'hist_metric',
|
||||
);
|
||||
});
|
||||
|
||||
it('Histogram to Sum: resets from p99 to the Sum defaults rate/sum', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'hist_metric',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'sum_metric',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'hist_metric',
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p99',
|
||||
metricName: 'hist_metric',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'sum_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('rate');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('sum');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'sum_metric',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('typed metric not in search results is committed with unknown defaults', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Gauge to unknown metric: resets from Gauge aggregations to unknown defaults (avg/avg)', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'cpu_usage',
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'max',
|
||||
metricName: 'cpu_usage',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'unknown_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Metric not in search results is committed with empty type resets to unknown defaults
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'unknown_metric',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summary metric type is treated as Gauge', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('selecting a Summary metric shows Gauge aggregation options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'rpc_duration_summary',
|
||||
type: MetrictypesTypeDTO.summary,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'rpc_duration_summary' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Latest',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'Count',
|
||||
'Count Distinct',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,266 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { AutoComplete, Spin, Typography } from 'antd';
|
||||
import { useListMetrics } from 'api/generated/services/metrics';
|
||||
import { MetricsexplorertypesListMetricDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ATTRIBUTE_TYPES, toAttributeType } from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
|
||||
import './MetricNameSelector.styles.scss';
|
||||
|
||||
export type MetricNameSelectorProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
|
||||
disabled?: boolean;
|
||||
defaultValue?: string;
|
||||
onSelect?: (value: BaseAutocompleteData) => void;
|
||||
signalSource?: 'meter' | '';
|
||||
};
|
||||
|
||||
function getAttributeType(
|
||||
metric: MetricsexplorertypesListMetricDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
return toAttributeType(metric.type, metric.isMonotonic);
|
||||
}
|
||||
|
||||
function createAutocompleteData(
|
||||
metricName: string,
|
||||
type: string,
|
||||
): BaseAutocompleteData {
|
||||
return { key: metricName, type, dataType: DataTypes.Float64 };
|
||||
}
|
||||
|
||||
// N.B on the metric name selector behaviour.
|
||||
//
|
||||
// Metric aggregation options resolution:
|
||||
// The component maintains a ref (metricsRef) of the latest API results.
|
||||
// When the user commits a metric name (via dropdown select, blur, or Cmd+Enter),
|
||||
// resolveMetricFromText looks up the metric in metricsRef to determine its type
|
||||
// (Sum, Gauge, Histogram, etc.). If the metric isn't found (e.g. the user typed
|
||||
// a name before the debounced search returned), the type is empty and downstream
|
||||
// treats it as unknown.
|
||||
//
|
||||
// Selection handling:
|
||||
// - Dropdown select: user picks from the dropdown; type is always resolved
|
||||
// since the option came from the current API results.
|
||||
// - Blur: user typed a name and tabbed/clicked away without selecting from
|
||||
// the dropdown. If the name differs from the current metric, it's resolved
|
||||
// and committed. If the input is empty, it resets to the current metric name.
|
||||
// - Cmd/Ctrl+Enter: resolves the typed name and commits it using flushSync
|
||||
// so the state update is processed synchronously before QueryBuilderV2's
|
||||
// onKeyDownCapture fires handleRunQuery. Uses document-level capture phase
|
||||
// to run before React's root-level event dispatch. However, there is still one
|
||||
// need to be handled here. TODO(srikanthccv): enter before n/w req completion
|
||||
//
|
||||
// Edit mode:
|
||||
// When a saved query is loaded, the metric name may be set via aggregations
|
||||
// but aggregateAttribute.type may be missing. Once the API returns metric data,
|
||||
// the component calls onChange with isEditMode=true to backfill the type without
|
||||
// resetting aggregation options.
|
||||
//
|
||||
// Signal source:
|
||||
// When signalSource is 'meter', the API is filtered to meter metrics only.
|
||||
// Changing signalSource clears the input and search text.
|
||||
|
||||
export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
query,
|
||||
onChange,
|
||||
disabled,
|
||||
defaultValue,
|
||||
onSelect,
|
||||
signalSource,
|
||||
}: MetricNameSelectorProps): JSX.Element {
|
||||
const currentMetricName =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key ||
|
||||
'';
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
currentMetricName || defaultValue || '',
|
||||
);
|
||||
const [searchText, setSearchText] = useState<string>(currentMetricName);
|
||||
|
||||
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
|
||||
const selectedFromDropdownRef = useRef(false);
|
||||
const prevSignalSourceRef = useRef(signalSource);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(currentMetricName || defaultValue || '');
|
||||
}, [defaultValue, currentMetricName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevSignalSourceRef.current !== signalSource) {
|
||||
prevSignalSourceRef.current = signalSource;
|
||||
setSearchText('');
|
||||
setInputValue('');
|
||||
}
|
||||
}, [signalSource]);
|
||||
|
||||
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
|
||||
const { isFetching, isError, data: listMetricsData } = useListMetrics(
|
||||
{
|
||||
searchText: debouncedValue,
|
||||
limit: 100,
|
||||
source: signalSource || undefined,
|
||||
} as Record<string, unknown>,
|
||||
{
|
||||
query: {
|
||||
keepPreviousData: false,
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const metrics = useMemo(() => listMetricsData?.data?.metrics ?? [], [
|
||||
listMetricsData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
metricsRef.current = metrics;
|
||||
}, [metrics]);
|
||||
|
||||
const optionsData = useMemo((): ExtendedSelectOption[] => {
|
||||
if (!metrics.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return metrics.map((metric) => ({
|
||||
label: (
|
||||
<OptionRenderer
|
||||
label={metric.metricName}
|
||||
value={metric.metricName}
|
||||
dataType={DataTypes.Float64}
|
||||
type={getAttributeType(metric) || ''}
|
||||
/>
|
||||
),
|
||||
value: metric.metricName,
|
||||
key: metric.metricName,
|
||||
}));
|
||||
}, [metrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
|
||||
const hasAggregateAttributeType = query.aggregateAttribute?.type;
|
||||
|
||||
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
|
||||
const found = metrics.find((m) => m.metricName === metricName);
|
||||
if (found) {
|
||||
onChange(
|
||||
createAutocompleteData(found.metricName, getAttributeType(found)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
|
||||
|
||||
const resolveMetricFromText = useCallback(
|
||||
(text: string): BaseAutocompleteData => {
|
||||
const found = metricsRef.current.find((m) => m.metricName === text);
|
||||
if (found) {
|
||||
return createAutocompleteData(found.metricName, getAttributeType(found));
|
||||
}
|
||||
return createAutocompleteData(text, '');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
if (signalSource === 'meter') {
|
||||
return 'Search for a meter metric...';
|
||||
}
|
||||
return 'Search for a metric...';
|
||||
}, [signalSource]);
|
||||
|
||||
const handleChange = useCallback((value: string): void => {
|
||||
setInputValue(value);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback((value: string): void => {
|
||||
setSearchText(value);
|
||||
selectedFromDropdownRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string): void => {
|
||||
selectedFromDropdownRef.current = true;
|
||||
const resolved = resolveMetricFromText(value);
|
||||
onChange(resolved);
|
||||
if (onSelect) {
|
||||
onSelect(resolved);
|
||||
}
|
||||
setSearchText('');
|
||||
},
|
||||
[onChange, onSelect, resolveMetricFromText],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (selectedFromDropdownRef.current) {
|
||||
selectedFromDropdownRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const typedValue = inputValue?.trim() || '';
|
||||
if (typedValue && typedValue !== currentMetricName) {
|
||||
onChange(resolveMetricFromText(typedValue));
|
||||
} else if (!typedValue && currentMetricName) {
|
||||
setInputValue(currentMetricName);
|
||||
}
|
||||
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
const typedValue = inputValue?.trim() || '';
|
||||
if (typedValue && typedValue !== currentMetricName) {
|
||||
flushSync(() => {
|
||||
onChange(resolveMetricFromText(typedValue));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
className="metric-name-selector"
|
||||
getPopupContainer={popupContainer}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
placeholder={placeholder}
|
||||
onSearch={handleSearch}
|
||||
onChange={handleChange}
|
||||
notFoundContent={
|
||||
isFetching ? (
|
||||
<Spin size="small" />
|
||||
) : isError ? (
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>
|
||||
Failed to load metrics
|
||||
</Typography.Text>
|
||||
) : null
|
||||
}
|
||||
options={optionsData}
|
||||
value={inputValue}
|
||||
onBlur={handleBlur}
|
||||
onSelect={handleSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { MetricNameSelectorProps } from './MetricNameSelector';
|
||||
export { MetricNameSelector } from './MetricNameSelector';
|
||||
@@ -2,7 +2,6 @@ export { AggregatorFilter } from './AggregatorFilter';
|
||||
export { BuilderUnitsFilter } from './BuilderUnitsFilter';
|
||||
export { GroupByFilter } from './GroupByFilter';
|
||||
export { HavingFilter } from './HavingFilter';
|
||||
export { MetricNameSelector } from './MetricNameSelector';
|
||||
export { OperatorsSelect } from './OperatorsSelect';
|
||||
export { OrderByFilter } from './OrderByFilter';
|
||||
export { ReduceToFilter } from './ReduceToFilter';
|
||||
|
||||
@@ -257,9 +257,7 @@ function TimeSeriesView({
|
||||
chartData[0]?.length === 0 &&
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
dataSource === DataSource.METRICS && (
|
||||
<EmptyMetricsSearch hasQueryResult={data !== undefined} />
|
||||
)}
|
||||
dataSource === DataSource.METRICS && <EmptyMetricsSearch />}
|
||||
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
|
||||
@@ -248,12 +248,19 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
);
|
||||
|
||||
const handleChangeAggregatorAttribute = useCallback(
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
|
||||
(
|
||||
value: BaseAutocompleteData,
|
||||
isEditMode?: boolean,
|
||||
attributeKeys?: BaseAutocompleteData[],
|
||||
): void => {
|
||||
const newQuery: IBuilderQuery = {
|
||||
...query,
|
||||
aggregateAttribute: value,
|
||||
};
|
||||
|
||||
const getAttributeKeyFromMetricName = (metricName: string): string =>
|
||||
attributeKeys?.find((key) => key.key === metricName)?.type || '';
|
||||
|
||||
if (
|
||||
newQuery.dataSource === DataSource.METRICS &&
|
||||
entityVersion === ENTITY_VERSION_V4
|
||||
@@ -304,7 +311,9 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
// Get current metric info
|
||||
const currentMetricType = newQuery.aggregateAttribute?.type || '';
|
||||
|
||||
const prevMetricType = previousMetricInfo?.type || '';
|
||||
const prevMetricType = previousMetricInfo?.type
|
||||
? previousMetricInfo.type
|
||||
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
|
||||
|
||||
// Check if metric type has changed by comparing with tracked previous values
|
||||
const metricTypeChanged =
|
||||
@@ -365,7 +374,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
|
||||
// Handled query with unknown metric to avoid 400 and 500 errors
|
||||
// With metric value typed and not available then - time - 'avg', space - 'avg'
|
||||
// If not typed - time - 'avg', space - 'sum'
|
||||
// If not typed - time - 'rate', space - 'sum', op - 'count'
|
||||
if (isEmpty(newQuery.aggregateAttribute?.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
|
||||
newQuery.aggregations = [
|
||||
@@ -379,7 +388,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
@@ -399,29 +408,6 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Override with safe defaults when metric type is unknown to avoid 400/500 errors
|
||||
if (isEmpty(newQuery.aggregateAttribute?.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ export const useHandleExplorerTabChange = (): {
|
||||
type: string,
|
||||
querySearchParameters?: ICurrentQueryData,
|
||||
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
newTab?: boolean,
|
||||
) => void;
|
||||
} => {
|
||||
const {
|
||||
@@ -64,7 +63,6 @@ export const useHandleExplorerTabChange = (): {
|
||||
type: string,
|
||||
currentQueryData?: ICurrentQueryData,
|
||||
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
newTab?: boolean,
|
||||
) => {
|
||||
const newPanelType = type as PANEL_TYPES;
|
||||
|
||||
@@ -83,21 +81,13 @@ export const useHandleExplorerTabChange = (): {
|
||||
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.name || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
redirectWithQueryBuilderData(query, {
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.name || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
|
||||
|
||||
@@ -54,7 +54,7 @@ export const stepIntervalUnchanged = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -177,7 +177,7 @@ export const replaceVariables = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -267,7 +267,7 @@ export const defaultOutput = {
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
},
|
||||
],
|
||||
filter: { expression: '' },
|
||||
@@ -392,7 +392,7 @@ export const outputWithFunctions = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -429,7 +429,7 @@ export const outputWithFunctions = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
|
||||
@@ -23,14 +22,6 @@ 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) {
|
||||
@@ -42,10 +33,10 @@ export default function Tooltip({
|
||||
return null;
|
||||
}
|
||||
return dayjs(data[0][cursorIdx] * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.tz(timezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
timezone,
|
||||
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: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
|
||||
timezone: 'UTC',
|
||||
content: [],
|
||||
showTooltipHeader: true,
|
||||
// TooltipRenderArgs (not used directly in component but required by type)
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function UPlotChart({
|
||||
|
||||
setPlotContextInitialState({
|
||||
uPlotInstance: plot,
|
||||
id: config.getId(),
|
||||
widgetId: config.getWidgetId(),
|
||||
shouldSaveSelectionPreference: config.getShouldSaveSelectionPreference(),
|
||||
});
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ const createMockConfig = (): UPlotConfigBuilder => {
|
||||
hooks: {},
|
||||
cursor: {},
|
||||
}),
|
||||
getId: jest.fn().mockReturnValue(undefined),
|
||||
getWidgetId: jest.fn().mockReturnValue(undefined),
|
||||
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
|
||||
} as unknown) as UPlotConfigBuilder;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
@@ -62,7 +61,7 @@ export interface TooltipRenderArgs {
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
timezone?: Timezone;
|
||||
timezone: string;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
|
||||
@@ -74,21 +74,23 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private tzDate: ((timestamp: number) => Date) | undefined;
|
||||
|
||||
private id: string;
|
||||
private widgetId: string | undefined;
|
||||
|
||||
private onDragSelect: (startTime: number, endTime: number) => void;
|
||||
|
||||
constructor(args: ConfigBuilderProps) {
|
||||
super(args);
|
||||
constructor(args?: ConfigBuilderProps) {
|
||||
super(args ?? {});
|
||||
const {
|
||||
id,
|
||||
widgetId,
|
||||
onDragSelect,
|
||||
tzDate,
|
||||
selectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
stepInterval,
|
||||
} = args ?? {};
|
||||
this.id = id;
|
||||
if (widgetId) {
|
||||
this.widgetId = widgetId;
|
||||
}
|
||||
|
||||
if (tzDate) {
|
||||
this.tzDate = tzDate;
|
||||
@@ -250,10 +252,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
*/
|
||||
private getStoredVisibility(): SeriesVisibilityItem[] | null {
|
||||
if (
|
||||
this.id &&
|
||||
this.widgetId &&
|
||||
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
|
||||
) {
|
||||
return getStoredSeriesVisibility(this.id);
|
||||
return getStoredSeriesVisibility(this.widgetId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -376,10 +378,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the id for the builder
|
||||
* Get the widget id
|
||||
*/
|
||||
getId(): string {
|
||||
return this.id;
|
||||
getWidgetId(): string | undefined {
|
||||
return this.widgetId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||
@@ -22,9 +23,6 @@ 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);
|
||||
@@ -55,7 +53,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
|
||||
const lineConfig: Partial<Series> = {
|
||||
stroke: resolvedLineColor,
|
||||
width: lineWidth ?? DEFAULT_LINE_WIDTH,
|
||||
width: lineWidth ?? 2,
|
||||
};
|
||||
|
||||
if (lineStyle === LineStyle.Dashed) {
|
||||
@@ -68,9 +66,9 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
|
||||
if (fillColor) {
|
||||
lineConfig.fill = fillColor;
|
||||
} else if (this.props.drawStyle === DrawStyle.Bar) {
|
||||
} else if (this.props.panelType === PANEL_TYPES.BAR) {
|
||||
lineConfig.fill = resolvedLineColor;
|
||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
||||
} else if (this.props.panelType === PANEL_TYPES.HISTOGRAM) {
|
||||
lineConfig.fill = `${resolvedLineColor}40`;
|
||||
}
|
||||
|
||||
@@ -139,19 +137,10 @@ 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: resolvedPointSize,
|
||||
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
|
||||
filter: pointsFilter || undefined,
|
||||
};
|
||||
|
||||
@@ -242,7 +231,7 @@ function getPathBuilder({
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Bar || drawStyle === DrawStyle.Histogram) {
|
||||
if (drawStyle === DrawStyle.Bar) {
|
||||
const pathBuilders = uPlot.paths;
|
||||
return getBarPathBuilder({
|
||||
pathBuilders,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
@@ -42,27 +43,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 id from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
it('returns widgetId from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
|
||||
|
||||
expect(builder.getId()).toBe('widget-123');
|
||||
expect(builder.getWidgetId()).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({ id: 'widget-123', tzDate });
|
||||
const builder = new UPlotConfigBuilder({ tzDate });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
@@ -71,7 +72,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
it('does not call onDragSelect for click without drag (width === 0)', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123', onDragSelect });
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
@@ -84,15 +85,14 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
// Simulate uPlot calling the hook
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
expect(setSelectHook).toBeDefined();
|
||||
setSelectHook?.(uplotInstance);
|
||||
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({ id: 'widget-123', onDragSelect });
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
@@ -111,8 +111,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
expect(setSelectHook).toBeDefined();
|
||||
setSelectHook?.(uplotInstance);
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).toHaveBeenCalledTimes(1);
|
||||
// 100 and 110 seconds converted to milliseconds
|
||||
@@ -120,7 +119,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const drawHook = jest.fn();
|
||||
|
||||
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
|
||||
@@ -135,7 +134,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds axes, scales, and series and wires them into the final config', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
// Add axis and scale
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
@@ -171,7 +170,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('merges axis when addAxis is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
|
||||
@@ -184,7 +183,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('merges scale when addScale is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addScale({ scaleKey: 'y', min: 0 });
|
||||
builder.addScale({ scaleKey: 'y', max: 100 });
|
||||
@@ -205,7 +204,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: 'widget-1',
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -232,7 +231,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: 'widget-1',
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -270,7 +269,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: 'widget-1',
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -303,7 +302,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: 'widget-dup',
|
||||
widgetId: 'widget-dup',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
@@ -330,7 +329,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
it('does not attempt to read stored visibility when using in-memory preferences', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: 'widget-1',
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
@@ -345,7 +344,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds thresholds only once per scale key', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
@@ -363,7 +362,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds multiple thresholds when scale key is different', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
@@ -384,7 +383,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('merges cursor configuration with defaults instead of replacing them', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
@@ -399,7 +398,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
describe('getCursorConfig', () => {
|
||||
it('returns default cursor merged with custom cursor when no stepInterval', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
@@ -413,7 +412,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('returns hover prox as DEFAULT_HOVER_PROXIMITY_VALUE when stepInterval is not set', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const cursorConfig = builder.getCursorConfig();
|
||||
|
||||
@@ -425,15 +424,15 @@ describe('UPlotConfigBuilder', () => {
|
||||
const mockWidth = 100;
|
||||
calculateWidthBasedOnStepIntervalMock.mockReturnValue(mockWidth);
|
||||
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123', stepInterval });
|
||||
const builder = new UPlotConfigBuilder({ stepInterval });
|
||||
const cursorConfig = builder.getCursorConfig();
|
||||
|
||||
expect(typeof cursorConfig.hover?.prox).toBe('function');
|
||||
|
||||
const uPlotInstance = {} as uPlot;
|
||||
const prox = cursorConfig.hover?.prox as ((u: uPlot) => number) | undefined;
|
||||
expect(prox).toBeDefined();
|
||||
const proxResult = prox ? prox(uPlotInstance) : NaN;
|
||||
const proxResult = (cursorConfig.hover!.prox as (u: uPlot) => number)(
|
||||
uPlotInstance,
|
||||
);
|
||||
|
||||
expect(calculateWidthBasedOnStepIntervalMock).toHaveBeenCalledWith({
|
||||
uPlotInstance,
|
||||
@@ -444,7 +443,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('adds plugins and includes them in config', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const plugin: uPlot.Plugin = {
|
||||
opts: (): void => {},
|
||||
hooks: {},
|
||||
@@ -459,7 +458,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({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
|
||||
|
||||
@@ -481,7 +480,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('does not include plugins when none added', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
@@ -489,7 +488,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('does not include bands when empty', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from '../types';
|
||||
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
@@ -18,6 +19,7 @@ const createBaseProps = (
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
isDarkMode: false,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -135,6 +137,7 @@ describe('UPlotSeriesBuilder', () => {
|
||||
const smallPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 4,
|
||||
pointSize: 2,
|
||||
}),
|
||||
);
|
||||
const largePointsBuilder = new UPlotSeriesBuilder(
|
||||
@@ -147,7 +150,7 @@ describe('UPlotSeriesBuilder', () => {
|
||||
const smallConfig = smallPointsBuilder.getConfig();
|
||||
const largeConfig = largePointsBuilder.getConfig();
|
||||
|
||||
expect(smallConfig.points?.size).toBe(4 * POINT_SIZE_FACTOR); // should be lineWidth * POINT_SIZE_FACTOR, when pointSize is not provided
|
||||
expect(smallConfig.points?.size).toBeUndefined();
|
||||
expect(largeConfig.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export enum SelectionPreferencesSource {
|
||||
* Props for configuring the uPlot config builder
|
||||
*/
|
||||
export interface ConfigBuilderProps {
|
||||
id: string;
|
||||
widgetId?: string;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
tzDate?: uPlot.LocalDateFromUnix;
|
||||
selectionPreferencesSource?: SelectionPreferencesSource;
|
||||
@@ -112,7 +112,6 @@ export enum DrawStyle {
|
||||
Line = 'line',
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
Histogram = 'histogram',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -169,6 +168,7 @@ 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;
|
||||
id?: string;
|
||||
widgetId?: 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 idRef = useRef<string | undefined>(undefined);
|
||||
const widgetIdRef = useRef<string | undefined>(undefined);
|
||||
const shouldSavePreferencesRef = useRef<boolean>(false);
|
||||
|
||||
const setPlotContextInitialState = useCallback(
|
||||
({
|
||||
uPlotInstance,
|
||||
id,
|
||||
widgetId,
|
||||
shouldSaveSelectionPreference,
|
||||
}: PlotContextInitialState): void => {
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
idRef.current = id;
|
||||
widgetIdRef.current = widgetId;
|
||||
activeSeriesIndex.current = undefined;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
@@ -50,7 +50,7 @@ export const PlotContextProvider = ({
|
||||
|
||||
const syncSeriesVisibilityToLocalStorage = useCallback((): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot || !idRef.current) {
|
||||
if (!plot || !widgetIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const PlotContextProvider = ({
|
||||
}),
|
||||
);
|
||||
|
||||
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
|
||||
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
|
||||
}, []);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback(
|
||||
@@ -84,7 +84,7 @@ export const PlotContextProvider = ({
|
||||
show: isReset || currentSeriesIndex === seriesIndex,
|
||||
});
|
||||
});
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export const PlotContextProvider = ({
|
||||
return;
|
||||
}
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,13 +32,13 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
|
||||
interface TestComponentProps {
|
||||
plot?: uPlot;
|
||||
id?: string;
|
||||
widgetId?: string;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
const TestComponent = ({
|
||||
plot,
|
||||
id,
|
||||
widgetId,
|
||||
shouldSaveSelectionPreference,
|
||||
}: TestComponentProps): JSX.Element => {
|
||||
const {
|
||||
@@ -49,13 +49,17 @@ const TestComponent = ({
|
||||
onFocusSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
if (
|
||||
!plot ||
|
||||
!widgetId ||
|
||||
typeof shouldSaveSelectionPreference !== 'boolean'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPlotContextInitialState({
|
||||
uPlotInstance: plot,
|
||||
id,
|
||||
widgetId,
|
||||
shouldSaveSelectionPreference,
|
||||
});
|
||||
};
|
||||
@@ -144,7 +148,11 @@ describe('PlotContext', () => {
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-123"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
@@ -191,7 +199,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
id="widget-visibility"
|
||||
widgetId="widget-visibility"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -232,7 +240,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
id="widget-reset"
|
||||
widgetId="widget-reset"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -282,7 +290,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
id="widget-toggle"
|
||||
widgetId="widget-toggle"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -308,7 +316,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
id="widget-missing-series"
|
||||
widgetId="widget-missing-series"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -333,7 +341,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
id="widget-no-persist"
|
||||
widgetId="widget-no-persist"
|
||||
shouldSaveSelectionPreference={false}
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
@@ -371,7 +379,7 @@ describe('PlotContext', () => {
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
id="widget-focus"
|
||||
widgetId="widget-focus"
|
||||
shouldSaveSelectionPreference={false}
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
|
||||
@@ -40,7 +40,7 @@ class TestConfigBuilder extends UPlotConfigBuilder {
|
||||
type ConfigMock = TestConfigBuilder;
|
||||
|
||||
function createConfigMock(): ConfigMock {
|
||||
return new TestConfigBuilder({ id: 'test-widget' });
|
||||
return new TestConfigBuilder();
|
||||
}
|
||||
|
||||
function getHandler(config: ConfigMock, hookName: string): HookHandler {
|
||||
|
||||
@@ -252,23 +252,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-details-v2 {
|
||||
.tabs-and-filters {
|
||||
.ant-tabs {
|
||||
.ant-tabs-tab {
|
||||
.ant-tabs-tab-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-400) !important;
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--bg-vanilla-200);
|
||||
color: var(--text-robin-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,25 +24,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.service-route-tab {
|
||||
.ant-tabs-nav {
|
||||
&::before {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-tabs-nav-wrap {
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-tab {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
|
||||
.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--text-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,7 +947,6 @@ export function QueryBuilderProvider({
|
||||
searchParams?: Record<string, unknown>,
|
||||
redirectingUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
shouldNotStringify?: boolean,
|
||||
newTab?: boolean,
|
||||
) => {
|
||||
const queryType =
|
||||
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
|
||||
@@ -1014,7 +1013,7 @@ export function QueryBuilderProvider({
|
||||
? `${redirectingUrl}?${urlQuery}`
|
||||
: `${location.pathname}?${urlQuery}`;
|
||||
|
||||
safeNavigate(generatedUrl, { newTab });
|
||||
safeNavigate(generatedUrl);
|
||||
},
|
||||
[location.pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ export type UseQueryOperations = (
|
||||
handleChangeAggregatorAttribute: (
|
||||
value: BaseAutocompleteData,
|
||||
isEditMode?: boolean,
|
||||
attributeKeys?: BaseAutocompleteData[],
|
||||
) => void;
|
||||
handleChangeDataSource: (newSource: DataSource) => void;
|
||||
handleDeleteQuery: () => void;
|
||||
|
||||
@@ -278,7 +278,6 @@ export type QueryBuilderContextType = {
|
||||
searchParams?: Record<string, unknown>,
|
||||
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
shallStringify?: boolean,
|
||||
newTab?: boolean,
|
||||
) => void;
|
||||
handleRunQuery: () => void;
|
||||
resetQuery: (newCurrentQuery?: QueryState) => void;
|
||||
|
||||
62
pkg/modules/cloudintegration/cloudintegration.go
Normal file
62
pkg/modules/cloudintegration/cloudintegration.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package cloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
GetName() cloudintegrationtypes.CloudProviderType
|
||||
|
||||
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
|
||||
AgentCheckIn(ctx context.Context, req *cloudintegrationtypes.PostableAgentCheckInPayload) (any, error)
|
||||
|
||||
GenerateConnectionParams(ctx context.Context) (*cloudintegrationtypes.GettableCloudIntegrationConnectionParams, error)
|
||||
// GenerateConnectionArtifact generates cloud provider specific connection information, client side handles how this information is shown
|
||||
GenerateConnectionArtifact(ctx context.Context, req *cloudintegrationtypes.PostableConnectionArtifact) (any, error)
|
||||
// GetAccountStatus returns agent connection status for a cloud integration account
|
||||
GetAccountStatus(ctx context.Context, orgID, accountID string) (*cloudintegrationtypes.GettableAccountStatus, error)
|
||||
// ListConnectedAccounts lists accounts where agent is connected
|
||||
ListConnectedAccounts(ctx context.Context, orgID string) (*cloudintegrationtypes.GettableConnectedAccountsList, error)
|
||||
|
||||
// LIstServices return list of services for a cloud provider attached with the accountID. This just returns a summary
|
||||
ListServices(ctx context.Context, orgID string, accountID *string) (any, error) // returns either GettableAWSServices or GettableAzureServices
|
||||
// GetServiceDetails returns service definition details for a serviceId. This returns config and other details required to show in service details page on client.
|
||||
GetServiceDetails(ctx context.Context, req *cloudintegrationtypes.GetServiceDetailsReq) (any, error)
|
||||
|
||||
// GetDashboard returns dashboard json for a give cloud integration service dashboard.
|
||||
// this only returns the dashboard when account is connected and service is enabled
|
||||
GetDashboard(ctx context.Context, id string, orgID valuer.UUID) (*dashboardtypes.Dashboard, error)
|
||||
// GetAvailableDashboards returns list of available dashboards across all connected cloud integration accounts in the org.
|
||||
// this list gets added to dashboard list page
|
||||
GetAvailableDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
|
||||
|
||||
// UpdateAccountConfig updates cloud integration account config
|
||||
UpdateAccountConfig(ctx context.Context, orgId valuer.UUID, accountId string, config []byte) (any, error)
|
||||
// UpdateServiceConfig updates cloud integration service config
|
||||
UpdateServiceConfig(ctx context.Context, serviceId string, orgID valuer.UUID, config []byte) (any, error)
|
||||
|
||||
// DisconnectAccount soft deletes/removes a cloud integration account.
|
||||
DisconnectAccount(ctx context.Context, orgID, accountID string) (*cloudintegrationtypes.CloudIntegration, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
AgentCheckIn(http.ResponseWriter, *http.Request)
|
||||
|
||||
GenerateConnectionParams(http.ResponseWriter, *http.Request)
|
||||
GenerateConnectionArtifact(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListConnectedAccounts(http.ResponseWriter, *http.Request)
|
||||
GetAccountStatus(http.ResponseWriter, *http.Request)
|
||||
ListServices(http.ResponseWriter, *http.Request)
|
||||
GetServiceDetails(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateAccountConfig(http.ResponseWriter, *http.Request)
|
||||
UpdateServiceConfig(http.ResponseWriter, *http.Request)
|
||||
|
||||
DisconnectAccount(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
193
pkg/modules/cloudintegration/implsqlstore/accounts.go
Normal file
193
pkg/modules/cloudintegration/implsqlstore/accounts.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
cloudintegrationtypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var ErrCodeCloudIntegrationAccountNotFound = errors.MustNewCode("cloud_integration_account_not_found")
|
||||
|
||||
// cloudProviderAccountsSQLRepository is a SQL-backed implementation of CloudIntegrationAccountStore.
|
||||
type cloudProviderAccountsSQLRepository struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
|
||||
// NewSQLCloudIntegrationAccountStore constructs a SQL-backed CloudIntegrationAccountStore.
|
||||
func NewSQLCloudIntegrationAccountStore(store sqlstore.SQLStore) cloudintegrationtypes.CloudIntegrationAccountStore {
|
||||
return &cloudProviderAccountsSQLRepository{store: store}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Account store implementation
|
||||
// -----------------------------
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) ListConnected(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) ([]cloudintegrationtypes.CloudIntegration, error) {
|
||||
accounts := []cloudintegrationtypes.CloudIntegration{}
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&accounts).
|
||||
Where("org_id = ?", orgId).
|
||||
Where("provider = ?", cloudProvider).
|
||||
Where("removed_at is NULL").
|
||||
Where("account_id is not NULL").
|
||||
Where("last_agent_report is not NULL").
|
||||
Order("created_at").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "error querying connected cloud accounts", "error", err)
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "could not query connected cloud accounts")
|
||||
}
|
||||
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) Get(
|
||||
ctx context.Context, orgId string, provider string, id string,
|
||||
) (*cloudintegrationtypes.CloudIntegration, error) {
|
||||
var result cloudintegrationtypes.CloudIntegration
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
Where("org_id = ?", orgId).
|
||||
Where("provider = ?", provider).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.WrapNotFoundf(
|
||||
err,
|
||||
ErrCodeCloudIntegrationAccountNotFound,
|
||||
"couldn't find account with Id %s", id,
|
||||
)
|
||||
}
|
||||
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud provider account")
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) GetConnectedCloudAccount(
|
||||
ctx context.Context, orgId string, provider string, accountId string,
|
||||
) (*cloudintegrationtypes.CloudIntegration, error) {
|
||||
var result cloudintegrationtypes.CloudIntegration
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
Where("org_id = ?", orgId).
|
||||
Where("provider = ?", provider).
|
||||
Where("account_id = ?", accountId).
|
||||
Where("last_agent_report is not NULL").
|
||||
Where("removed_at is NULL").
|
||||
Scan(ctx)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.WrapNotFoundf(err, ErrCodeCloudIntegrationAccountNotFound, "couldn't find connected cloud account %s", accountId)
|
||||
} else if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud provider account")
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) Upsert(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
provider string,
|
||||
id *string,
|
||||
config []byte,
|
||||
accountId *string,
|
||||
agentReport *cloudintegrationtypes.AgentReport,
|
||||
removedAt *time.Time,
|
||||
) (*cloudintegrationtypes.CloudIntegration, error) {
|
||||
// Insert
|
||||
if id == nil {
|
||||
temp := valuer.GenerateUUID().StringValue()
|
||||
id = &temp
|
||||
}
|
||||
|
||||
// Prepare clause for setting values in `on conflict do update`
|
||||
onConflictSetStmts := []string{}
|
||||
setColStatement := func(col string) string {
|
||||
return fmt.Sprintf("%s=excluded.%s", col, col)
|
||||
}
|
||||
|
||||
if config != nil {
|
||||
onConflictSetStmts = append(
|
||||
onConflictSetStmts, setColStatement("config"),
|
||||
)
|
||||
}
|
||||
|
||||
if accountId != nil {
|
||||
onConflictSetStmts = append(
|
||||
onConflictSetStmts, setColStatement("account_id"),
|
||||
)
|
||||
}
|
||||
|
||||
if agentReport != nil {
|
||||
onConflictSetStmts = append(
|
||||
onConflictSetStmts, setColStatement("last_agent_report"),
|
||||
)
|
||||
}
|
||||
|
||||
if removedAt != nil {
|
||||
onConflictSetStmts = append(
|
||||
onConflictSetStmts, setColStatement("removed_at"),
|
||||
)
|
||||
}
|
||||
|
||||
// set updated_at to current timestamp if it's an upsert
|
||||
onConflictSetStmts = append(
|
||||
onConflictSetStmts, setColStatement("updated_at"),
|
||||
)
|
||||
|
||||
onConflictClause := ""
|
||||
if len(onConflictSetStmts) > 0 {
|
||||
onConflictClause = fmt.Sprintf(
|
||||
"conflict(id, provider, org_id) do update SET\n%s",
|
||||
strings.Join(onConflictSetStmts, ",\n"),
|
||||
)
|
||||
}
|
||||
|
||||
integration := cloudintegrationtypes.CloudIntegration{
|
||||
OrgID: orgId,
|
||||
Provider: provider,
|
||||
Identifiable: types.Identifiable{ID: valuer.MustNewUUID(*id)},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Config: string(config),
|
||||
AccountID: accountId,
|
||||
LastAgentReport: agentReport,
|
||||
RemovedAt: removedAt,
|
||||
}
|
||||
|
||||
_, err := r.store.BunDB().NewInsert().
|
||||
Model(&integration).
|
||||
On(onConflictClause).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't upsert cloud integration account")
|
||||
}
|
||||
|
||||
upsertedAccount, err := r.Get(ctx, orgId, provider, *id)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "error upserting cloud integration account", "error", err)
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't get upserted cloud integration account")
|
||||
}
|
||||
|
||||
return upsertedAccount, nil
|
||||
}
|
||||
133
pkg/modules/cloudintegration/implsqlstore/serviceconfig.go
Normal file
133
pkg/modules/cloudintegration/implsqlstore/serviceconfig.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
cloudintegrationtypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var ErrCodeServiceConfigNotFound = errors.MustNewCode("service_config_not_found")
|
||||
|
||||
// serviceConfigSQLRepository is a SQL-backed implementation of CloudIntegrationServiceStore.
|
||||
type serviceConfigSQLRepository struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
|
||||
// NewSQLCloudIntegrationServiceStore constructs a SQL-backed CloudIntegrationServiceStore.
|
||||
func NewSQLCloudIntegrationServiceStore(store sqlstore.SQLStore) cloudintegrationtypes.CloudIntegrationServiceStore {
|
||||
return &serviceConfigSQLRepository{store: store}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Service config store implementation
|
||||
// -----------------------------
|
||||
|
||||
func (r *serviceConfigSQLRepository) Get(
|
||||
ctx context.Context,
|
||||
orgID string,
|
||||
cloudAccountId string,
|
||||
serviceType string,
|
||||
) ([]byte, error) {
|
||||
var result cloudintegrationtypes.CloudIntegrationService
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
Join("JOIN cloud_integration ci ON ci.id = cis.cloud_integration_id").
|
||||
Where("ci.org_id = ?", orgID).
|
||||
Where("ci.id = ?", cloudAccountId).
|
||||
Where("cis.type = ?", serviceType).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.WrapNotFoundf(err, ErrCodeServiceConfigNotFound, "couldn't find config for cloud account %s", cloudAccountId)
|
||||
}
|
||||
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud service config")
|
||||
}
|
||||
|
||||
return []byte(result.Config), nil
|
||||
}
|
||||
|
||||
func (r *serviceConfigSQLRepository) Upsert(
|
||||
ctx context.Context,
|
||||
orgID string,
|
||||
cloudProvider string,
|
||||
cloudAccountId string,
|
||||
serviceId string,
|
||||
config []byte,
|
||||
) ([]byte, error) {
|
||||
// get cloud integration id from account id
|
||||
// if the account is not connected, we don't need to upsert the config
|
||||
var cloudIntegrationId string
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model((*cloudintegrationtypes.CloudIntegration)(nil)).
|
||||
Column("id").
|
||||
Where("provider = ?", cloudProvider).
|
||||
Where("account_id = ?", cloudAccountId).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("removed_at is NULL").
|
||||
Where("last_agent_report is not NULL").
|
||||
Scan(ctx, &cloudIntegrationId)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.WrapNotFoundf(
|
||||
err,
|
||||
ErrCodeCloudIntegrationAccountNotFound,
|
||||
"couldn't find active cloud integration account",
|
||||
)
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud integration id")
|
||||
}
|
||||
|
||||
serviceConfig := cloudintegrationtypes.CloudIntegrationService{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Config: string(config),
|
||||
Type: serviceId,
|
||||
CloudIntegrationID: cloudIntegrationId,
|
||||
}
|
||||
_, err = r.store.BunDB().NewInsert().
|
||||
Model(&serviceConfig).
|
||||
On("conflict(cloud_integration_id, type) do update set config=excluded.config, updated_at=excluded.updated_at").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't upsert cloud service config")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (r *serviceConfigSQLRepository) GetAllForAccount(
|
||||
ctx context.Context,
|
||||
orgID string,
|
||||
cloudAccountId string,
|
||||
) (map[string][]byte, error) {
|
||||
var serviceConfigs []cloudintegrationtypes.CloudIntegrationService
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&serviceConfigs).
|
||||
Join("JOIN cloud_integration ci ON ci.id = cis.cloud_integration_id").
|
||||
Where("ci.id = ?", cloudAccountId).
|
||||
Where("ci.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query service configs from db")
|
||||
}
|
||||
|
||||
result := make(map[string][]byte)
|
||||
|
||||
for _, r := range serviceConfigs {
|
||||
result[r.Type] = []byte(r.Config)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
@@ -57,81 +56,11 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): use metadata store to fetch metric metadata
|
||||
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params.Source == "meter" {
|
||||
return m.listMeterMetrics(ctx, params)
|
||||
}
|
||||
return m.listMetrics(ctx, orgID, params)
|
||||
}
|
||||
|
||||
func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"metric_name",
|
||||
"any(description) AS description",
|
||||
"any(type) AS metric_type",
|
||||
"any(unit) AS metric_unit",
|
||||
"argMax(temporality, unix_milli) AS temporality",
|
||||
"any(is_monotonic) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
|
||||
|
||||
if params.Start != nil && params.End != nil {
|
||||
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
|
||||
}
|
||||
|
||||
if params.Search != "" {
|
||||
searchLower := strings.ToLower(params.Search)
|
||||
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
|
||||
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
|
||||
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
|
||||
}
|
||||
|
||||
sb.GroupBy("metric_name")
|
||||
sb.OrderBy("metric_name ASC")
|
||||
sb.Limit(params.Limit)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
|
||||
db := m.telemetryStore.ClickhouseDB()
|
||||
rows, err := db.Query(valueCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list meter metrics")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
metrics := make([]metricsexplorertypes.ListMetric, 0)
|
||||
for rows.Next() {
|
||||
var metric metricsexplorertypes.ListMetric
|
||||
if err := rows.Scan(
|
||||
&metric.MetricName,
|
||||
&metric.Description,
|
||||
&metric.MetricType,
|
||||
&metric.MetricUnit,
|
||||
&metric.Temporality,
|
||||
&metric.IsMonotonic,
|
||||
); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan meter metric")
|
||||
}
|
||||
metrics = append(metrics, metric)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating meter metrics")
|
||||
}
|
||||
|
||||
return &metricsexplorertypes.ListMetricsResponse{
|
||||
Metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT metric_name")
|
||||
|
||||
@@ -786,8 +715,9 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
|
||||
Logger: m.logger,
|
||||
FieldMapper: m.fieldMapper,
|
||||
ConditionBuilder: m.condBuilder,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "labels"},
|
||||
FieldKeys: keys,
|
||||
}
|
||||
|
||||
startNs := querybuilder.ToNanoSecs(uint64(startMillis))
|
||||
|
||||
@@ -1631,10 +1631,8 @@ func (t *telemetryMetaStore) FetchTemporalityAndTypeMulti(ctx context.Context, q
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meterMetricsTemporality, meterMetricsTypes, err := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// TODO: return error after table migration are run
|
||||
meterMetricsTemporality, meterMetricsTypes, _ := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
|
||||
|
||||
// For metrics not found in the database, set to Unknown
|
||||
for _, metricName := range metricNames {
|
||||
@@ -1730,7 +1728,6 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
|
||||
"metric_name",
|
||||
"argMax(temporality, unix_milli) as temporality",
|
||||
"any(type) AS type",
|
||||
"any(is_monotonic) as is_monotonic",
|
||||
).From(t.meterDBName + "." + t.meterFieldsTblName)
|
||||
|
||||
// Filter by metric names (in the temporality column due to data mix-up)
|
||||
|
||||
578
pkg/types/cloudintegrationtypes/cloudintegration.go
Normal file
578
pkg/types/cloudintegrationtypes/cloudintegration.go
Normal file
@@ -0,0 +1,578 @@
|
||||
// NOTE:
|
||||
// - When Account keyword is used in struct names, it refers cloud integration account. CloudIntegration refers to DB schema.
|
||||
// - When Account Config keyword is used in struct names, it refers to configuration for cloud integration accounts
|
||||
// - When Service keyword is used in struct names, it refers to cloud integration service. CloudIntegrationService refers to DB schema.
|
||||
// where `service` is services provided by each cloud provider like AWS S3, Azure BlobStorage etc.
|
||||
// - When Service Config keyword is used in struct names, it refers to configuration for cloud integration services
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// CloudProviderType type alias
|
||||
type CloudProviderType struct{ valuer.String }
|
||||
|
||||
var (
|
||||
CloudProviderTypeAWS = CloudProviderType{valuer.NewString("aws")}
|
||||
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
|
||||
)
|
||||
|
||||
var ErrCodeCloudProviderInvalidInput = errors.MustNewCode("invalid_cloud_provider")
|
||||
|
||||
// NewCloudProvider returns a new CloudProviderType from a string. It validates the input and returns an error if the input is not valid.
|
||||
func NewCloudProvider(provider string) (CloudProviderType, error) {
|
||||
switch provider {
|
||||
case CloudProviderTypeAWS.StringValue():
|
||||
return CloudProviderTypeAWS, nil
|
||||
case CloudProviderTypeAzure.StringValue():
|
||||
return CloudProviderTypeAzure, nil
|
||||
default:
|
||||
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
AWSIntegrationUserEmail = valuer.MustNewEmail("aws-integration@signoz.io")
|
||||
AzureIntegrationUserEmail = valuer.MustNewEmail("azure-integration@signoz.io")
|
||||
)
|
||||
|
||||
// CloudIntegrationUserEmails is the list of valid emails for Cloud One Click integrations.
|
||||
// This is used for validation and restrictions in different contexts, across codebase.
|
||||
var CloudIntegrationUserEmails = []valuer.Email{
|
||||
AWSIntegrationUserEmail,
|
||||
AzureIntegrationUserEmail,
|
||||
}
|
||||
|
||||
func IsCloudIntegrationDashboardUuid(dashboardUuid string) bool {
|
||||
parts := strings.SplitN(dashboardUuid, "--", 4)
|
||||
if len(parts) != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
return parts[0] == "cloud-integration"
|
||||
}
|
||||
|
||||
// GetCloudIntegrationDashboardID returns the cloud provider from dashboard id, if it's a cloud integration dashboard id.
|
||||
// throws an error if invalid format or invalid cloud provider is provided in the dashboard id.
|
||||
func GetCloudProviderFromDashboardID(dashboardUuid string) (CloudProviderType, error) {
|
||||
parts := strings.SplitN(dashboardUuid, "--", 4)
|
||||
if len(parts) != 4 {
|
||||
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid dashboard uuid: %s", dashboardUuid)
|
||||
}
|
||||
|
||||
providerStr := parts[1]
|
||||
|
||||
cloudProvider, err := NewCloudProvider(providerStr)
|
||||
if err != nil {
|
||||
return CloudProviderType{}, err
|
||||
}
|
||||
|
||||
return cloudProvider, nil
|
||||
}
|
||||
|
||||
// Generic utility functions for JSON serialization/deserialization
|
||||
// this is helpful to return right errors from a common place and avoid repeating the same code in multiple places.
|
||||
// UnmarshalJSON is a generic function to unmarshal JSON data into any type
|
||||
func UnmarshalJSON[T any](src []byte, target *T) error {
|
||||
err := json.Unmarshal(src, target)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(
|
||||
err, errors.CodeInternal, "couldn't deserialize JSON",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON is a generic function to marshal any type to JSON
|
||||
func MarshalJSON[T any](source *T) ([]byte, error) {
|
||||
if source == nil {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "source is nil")
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(
|
||||
err, errors.CodeInternal, "couldn't serialize to JSON",
|
||||
)
|
||||
}
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
// GettableConnectedAccountsList is the response for listing connected accounts for a cloud provider.
|
||||
type GettableConnectedAccountsList struct {
|
||||
Accounts []*Account `json:"accounts"`
|
||||
}
|
||||
|
||||
// SigNozAgentConfig represents parameters required for agent deployment in cloud provider accounts
|
||||
// these represent parameters passed during agent deployment, how they are passed might change for each cloud provider but the purpose is same.
|
||||
type SigNozAgentConfig struct {
|
||||
Region string `json:"region,omitempty"` // AWS-specific: The region in which SigNoz agent should be installed
|
||||
|
||||
IngestionUrl string `json:"ingestion_url"`
|
||||
IngestionKey string `json:"ingestion_key"`
|
||||
SigNozAPIUrl string `json:"signoz_api_url"`
|
||||
SigNozAPIKey string `json:"signoz_api_key"`
|
||||
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// PostableConnectionArtifact represent request body for generating connection artifact API.
|
||||
// Data is request body raw bytes since each cloud provider will have have different request body structure and generics hardly help in such cases.
|
||||
// Artifact is a generic name for different types of connection methods like connection URL for AWS, connection command for Azure etc.
|
||||
type PostableConnectionArtifact struct {
|
||||
OrgID string
|
||||
Data []byte // either PostableAWSConnectionUrl or PostableAzureConnectionCommand
|
||||
}
|
||||
|
||||
// PostableAWSConnectionUrl is request body for AWS connection artifact API
|
||||
type PostableAWSConnectionUrl struct {
|
||||
AgentConfig *SigNozAgentConfig `json:"agent_config"`
|
||||
AccountConfig *AWSAccountConfig `json:"account_config"`
|
||||
}
|
||||
|
||||
// PostableAzureConnectionCommand is request body for Azure connection artifact API
|
||||
type PostableAzureConnectionCommand struct {
|
||||
AgentConfig *SigNozAgentConfig `json:"agent_config"`
|
||||
AccountConfig *AzureAccountConfig `json:"account_config"`
|
||||
}
|
||||
|
||||
// GettableAzureConnectionArtifact is Azure specific connection artifact which contains connection commands for agent deployment
|
||||
type GettableAzureConnectionArtifact struct {
|
||||
AzureShellConnectionCommand string `json:"az_shell_connection_command"`
|
||||
AzureCliConnectionCommand string `json:"az_cli_connection_command"`
|
||||
}
|
||||
|
||||
// GettableAWSConnectionUrl is AWS specific connection artifact which contains connection url for agent deployment
|
||||
type GettableAWSConnectionUrl struct {
|
||||
AccountId string `json:"account_id"`
|
||||
ConnectionUrl string `json:"connection_url"`
|
||||
}
|
||||
|
||||
// GettableAzureConnectionCommand is Azure specific connection artifact which contains connection commands for agent deployment
|
||||
type GettableAzureConnectionCommand struct {
|
||||
AccountId string `json:"account_id"`
|
||||
AzureShellConnectionCommand string `json:"az_shell_connection_command"`
|
||||
AzureCliConnectionCommand string `json:"az_cli_connection_command"`
|
||||
}
|
||||
|
||||
// GettableAccountStatus is cloud integration account status response
|
||||
type GettableAccountStatus struct {
|
||||
Id string `json:"id"`
|
||||
CloudAccountId *string `json:"cloud_account_id,omitempty"`
|
||||
Status AccountStatus `json:"status"`
|
||||
}
|
||||
|
||||
// PostableAgentCheckInPayload is request body for agent check-in API.
|
||||
// This is used by agent to send heartbeat.
|
||||
type PostableAgentCheckInPayload struct {
|
||||
ID string `json:"account_id"`
|
||||
AccountID string `json:"cloud_account_id"`
|
||||
// Arbitrary cloud specific Agent data
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
OrgID string `json:"-"`
|
||||
}
|
||||
|
||||
// AWSAgentIntegrationConfig is used by agent for deploying infra to send telemetry to SigNoz
|
||||
type AWSAgentIntegrationConfig struct {
|
||||
EnabledRegions []string `json:"enabled_regions"`
|
||||
TelemetryCollectionStrategy *AWSCollectionStrategy `json:"telemetry,omitempty"`
|
||||
}
|
||||
|
||||
// AzureAgentIntegrationConfig is used by agent for deploying infra to send telemetry to SigNoz
|
||||
type AzureAgentIntegrationConfig struct {
|
||||
DeploymentRegion string `json:"deployment_region"` // will not be changed once set
|
||||
EnabledResourceGroups []string `json:"resource_groups"`
|
||||
// TelemetryCollectionStrategy is map of service to telemetry config
|
||||
TelemetryCollectionStrategy map[string]*AzureCollectionStrategy `json:"telemetry,omitempty"`
|
||||
}
|
||||
|
||||
// GettableAgentCheckInRes is generic response from agent check-in API.
|
||||
// AWSAgentIntegrationConfig and AzureAgentIntegrationConfig these configs are used by agent to deploy the infra and send telemetry to SigNoz
|
||||
type GettableAgentCheckInRes[AgentConfigT any] struct {
|
||||
AccountId string `json:"account_id"`
|
||||
CloudAccountId string `json:"cloud_account_id"`
|
||||
RemovedAt *time.Time `json:"removed_at"`
|
||||
IntegrationConfig AgentConfigT `json:"integration_config"`
|
||||
}
|
||||
|
||||
// UpdatableServiceConfig is generic
|
||||
type UpdatableServiceConfig[ServiceConfigT any] struct {
|
||||
CloudAccountId string `json:"cloud_account_id"`
|
||||
Config ServiceConfigT `json:"config"`
|
||||
}
|
||||
|
||||
// ServiceConfigTyped is a generic interface for cloud integration service's configuration
|
||||
// this is generic interface to define helper functions for CloudIntegrationService.Config field.
|
||||
type ServiceConfigTyped[definition Definition] interface {
|
||||
Validate(def definition) error
|
||||
IsMetricsEnabled() bool
|
||||
IsLogsEnabled() bool
|
||||
}
|
||||
|
||||
type AWSServiceConfig struct {
|
||||
Logs *AWSServiceLogsConfig `json:"logs,omitempty"`
|
||||
Metrics *AWSServiceMetricsConfig `json:"metrics,omitempty"`
|
||||
}
|
||||
|
||||
type AWSServiceLogsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
|
||||
}
|
||||
|
||||
type AWSServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// IsMetricsEnabled returns true if metrics collection is configured and enabled
|
||||
func (a *AWSServiceConfig) IsMetricsEnabled() bool {
|
||||
return a.Metrics != nil && a.Metrics.Enabled
|
||||
}
|
||||
|
||||
// IsLogsEnabled returns true if logs collection is configured and enabled
|
||||
func (a *AWSServiceConfig) IsLogsEnabled() bool {
|
||||
return a.Logs != nil && a.Logs.Enabled
|
||||
}
|
||||
|
||||
type AzureServiceConfig struct {
|
||||
Logs []*AzureServiceLogsConfig `json:"logs,omitempty"`
|
||||
Metrics []*AzureServiceMetricsConfig `json:"metrics,omitempty"`
|
||||
}
|
||||
|
||||
// AzureServiceLogsConfig is Azure specific service config for logs
|
||||
type AzureServiceLogsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AzureServiceMetricsConfig is Azure specific service config for metrics
|
||||
type AzureServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// IsMetricsEnabled returns true if any metric is configured and enabled
|
||||
func (a *AzureServiceConfig) IsMetricsEnabled() bool {
|
||||
if a.Metrics == nil {
|
||||
return false
|
||||
}
|
||||
for _, m := range a.Metrics {
|
||||
if m.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsLogsEnabled returns true if any log is configured and enabled
|
||||
func (a *AzureServiceConfig) IsLogsEnabled() bool {
|
||||
if a.Logs == nil {
|
||||
return false
|
||||
}
|
||||
for _, l := range a.Logs {
|
||||
if l.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *AWSServiceConfig) Validate(def *AWSDefinition) error {
|
||||
if def.Id != S3Sync.String() && a.Logs != nil && a.Logs.S3Buckets != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "s3 buckets can only be added to service-type[%s]", S3Sync)
|
||||
} else if def.Id == S3Sync.String() && a.Logs != nil && a.Logs.S3Buckets != nil {
|
||||
for region := range a.Logs.S3Buckets {
|
||||
if _, found := ValidAWSRegions[region]; !found {
|
||||
return errors.NewInvalidInputf(CodeInvalidCloudRegion, "invalid cloud region: %s", region)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AzureServiceConfig) Validate(def *AzureDefinition) error {
|
||||
logsMap := make(map[string]bool)
|
||||
metricsMap := make(map[string]bool)
|
||||
|
||||
if def.Strategy != nil && def.Strategy.Logs != nil {
|
||||
for _, log := range def.Strategy.Logs {
|
||||
logsMap[log.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
if def.Strategy != nil && def.Strategy.Metrics != nil {
|
||||
for _, metric := range def.Strategy.Metrics {
|
||||
metricsMap[metric.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, log := range a.Logs {
|
||||
if _, found := logsMap[log.Name]; !found {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid log name: %s", log.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, metric := range a.Metrics {
|
||||
if _, found := metricsMap[metric.Name]; !found {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid metric name: %s", metric.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatableServiceConfigRes is response for UpdateServiceConfig API
|
||||
// TODO: find a better way to name this
|
||||
type UpdatableServiceConfigRes struct {
|
||||
ServiceId string `json:"id"`
|
||||
Config any `json:"config"`
|
||||
}
|
||||
|
||||
// UpdatableAccountConfigTyped is a generic struct for updating cloud integration account config used in UpdateAccountConfig API
|
||||
type UpdatableAccountConfigTyped[AccountConfigT any] struct {
|
||||
Config *AccountConfigT `json:"config"`
|
||||
}
|
||||
|
||||
type (
|
||||
UpdatableAWSAccountConfig = UpdatableAccountConfigTyped[AWSAccountConfig]
|
||||
UpdatableAzureAccountConfig = UpdatableAccountConfigTyped[AzureAccountConfig]
|
||||
)
|
||||
|
||||
// AWSAccountConfig is the configuration for AWS cloud integration account
|
||||
type AWSAccountConfig struct {
|
||||
EnabledRegions []string `json:"regions"`
|
||||
}
|
||||
|
||||
// AzureAccountConfig is the configuration for Azure cloud integration account
|
||||
type AzureAccountConfig struct {
|
||||
DeploymentRegion string `json:"deployment_region,omitempty"`
|
||||
EnabledResourceGroups []string `json:"resource_groups,omitempty"`
|
||||
}
|
||||
|
||||
// GettableServices is a generic struct for listing services of a cloud integration account used in ListServices API
|
||||
type GettableServices[ServiceSummaryT any] struct {
|
||||
Services []ServiceSummaryT `json:"services"`
|
||||
}
|
||||
|
||||
type (
|
||||
GettableAWSServices = GettableServices[AWSServiceSummary]
|
||||
GettableAzureServices = GettableServices[AzureServiceSummary]
|
||||
)
|
||||
|
||||
// GetServiceDetailsReq is a req struct for getting service definition details
|
||||
type GetServiceDetailsReq struct {
|
||||
OrgID valuer.UUID
|
||||
ServiceId string
|
||||
CloudAccountID *string
|
||||
}
|
||||
|
||||
// ServiceSummary is a generic struct for service summary used in ListServices API
|
||||
type ServiceSummary[ServiceConfigT any] struct {
|
||||
DefinitionMetadata
|
||||
Config *ServiceConfigT `json:"config"`
|
||||
}
|
||||
|
||||
type (
|
||||
AWSServiceSummary = ServiceSummary[AWSServiceConfig]
|
||||
AzureServiceSummary = ServiceSummary[AzureServiceConfig]
|
||||
)
|
||||
|
||||
// GettableServiceDetails is a generic struct for service details used in GetServiceDetails API
|
||||
type GettableServiceDetails[DefinitionT any, ServiceConfigT any] struct {
|
||||
Definition DefinitionT `json:",inline"`
|
||||
Config ServiceConfigT `json:"config"`
|
||||
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
GettableAWSServiceDetails = GettableServiceDetails[AWSDefinition, *AWSServiceConfig]
|
||||
GettableAzureServiceDetails = GettableServiceDetails[AzureDefinition, *AzureServiceConfig]
|
||||
)
|
||||
|
||||
// ServiceConnectionStatus represents integration connection status for a particular service
|
||||
// this struct helps to check ingested data and determines connection status by whether data was ingested or not.
|
||||
// this is composite struct for both metrics and logs
|
||||
type ServiceConnectionStatus struct {
|
||||
Logs []*SignalConnectionStatus `json:"logs"`
|
||||
Metrics []*SignalConnectionStatus `json:"metrics"`
|
||||
}
|
||||
|
||||
// SignalConnectionStatus represents connection status for a particular signal type (logs or metrics) for a service
|
||||
// this struct is used in API responses for clients to show relevant information about the connection status.
|
||||
type SignalConnectionStatus struct {
|
||||
CategoryID string `json:"category"`
|
||||
CategoryDisplayName string `json:"category_display_name"`
|
||||
LastReceivedTsMillis int64 `json:"last_received_ts_ms"` // epoch milliseconds
|
||||
LastReceivedFrom string `json:"last_received_from"` // resource identifier
|
||||
}
|
||||
|
||||
// GettableCloudIntegrationConnectionParams is response for connection params API
|
||||
type GettableCloudIntegrationConnectionParams struct {
|
||||
IngestionUrl string `json:"ingestion_url,omitempty"`
|
||||
IngestionKey string `json:"ingestion_key,omitempty"`
|
||||
SigNozAPIUrl string `json:"signoz_api_url,omitempty"`
|
||||
SigNozAPIKey string `json:"signoz_api_key,omitempty"`
|
||||
}
|
||||
|
||||
// GettableIngestionKey is a struct for ingestion key returned from gateway
|
||||
type GettableIngestionKey struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
// other attributes from gateway response not included here since they are not being used.
|
||||
}
|
||||
|
||||
// GettableIngestionKeysSearch is a struct for response of ingestion keys search API on gateway
|
||||
type GettableIngestionKeysSearch struct {
|
||||
Status string `json:"status"`
|
||||
Data []GettableIngestionKey `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// GettableCreateIngestionKey is a struct for response of create ingestion key API on gateway
|
||||
type GettableCreateIngestionKey struct {
|
||||
Status string `json:"status"`
|
||||
Data GettableIngestionKey `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// GettableDeployment is response struct for deployment details fetched from Zeus
|
||||
type GettableDeployment struct {
|
||||
Name string `json:"name"`
|
||||
ClusterInfo struct {
|
||||
Region struct {
|
||||
DNS string `json:"dns"`
|
||||
} `json:"region"`
|
||||
} `json:"cluster"`
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// DATABASE TYPES
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Cloud integration uses the cloud_integration table
|
||||
// and cloud_integrations_service table
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type CloudIntegration struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Provider string `json:"provider" bun:"provider,type:text,unique:provider_id"`
|
||||
Config string `json:"config" bun:"config,type:text"` // json serialized config
|
||||
AccountID *string `json:"account_id" bun:"account_id,type:text"`
|
||||
LastAgentReport *AgentReport `json:"last_agent_report" bun:"last_agent_report,type:text"`
|
||||
RemovedAt *time.Time `json:"removed_at" bun:"removed_at,type:timestamp,nullzero"`
|
||||
OrgID string `bun:"org_id,type:text,unique:provider_id"`
|
||||
}
|
||||
|
||||
// Account represents a cloud integration account, this is used for business logic and API responses.
|
||||
type Account struct {
|
||||
Id string `json:"id"`
|
||||
CloudAccountId string `json:"cloud_account_id"`
|
||||
Config any `json:"config"` // AWSAccountConfig or AzureAccountConfig
|
||||
Status AccountStatus `json:"status"`
|
||||
}
|
||||
|
||||
// AccountStatus is generic struct for cloud integration account status
|
||||
type AccountStatus struct {
|
||||
Integration AccountIntegrationStatus `json:"integration"`
|
||||
}
|
||||
|
||||
// AccountIntegrationStatus stores heartbeat information from agent check in
|
||||
type AccountIntegrationStatus struct {
|
||||
LastHeartbeatTsMillis *int64 `json:"last_heartbeat_ts_ms"`
|
||||
}
|
||||
|
||||
func (a *CloudIntegration) Status() AccountStatus {
|
||||
status := AccountStatus{}
|
||||
if a.LastAgentReport != nil {
|
||||
lastHeartbeat := a.LastAgentReport.TimestampMillis
|
||||
status.Integration.LastHeartbeatTsMillis = &lastHeartbeat
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (a *CloudIntegration) Account(cloudProvider CloudProviderType) *Account {
|
||||
ca := &Account{Id: a.ID.StringValue(), Status: a.Status()}
|
||||
|
||||
if a.AccountID != nil {
|
||||
ca.CloudAccountId = *a.AccountID
|
||||
}
|
||||
|
||||
ca.Config = map[string]interface{}{}
|
||||
|
||||
if len(a.Config) < 1 {
|
||||
return ca
|
||||
}
|
||||
|
||||
switch cloudProvider {
|
||||
case CloudProviderTypeAWS:
|
||||
config := new(AWSAccountConfig)
|
||||
_ = UnmarshalJSON([]byte(a.Config), config)
|
||||
ca.Config = config
|
||||
case CloudProviderTypeAzure:
|
||||
config := new(AzureAccountConfig)
|
||||
_ = UnmarshalJSON([]byte(a.Config), config)
|
||||
ca.Config = config
|
||||
default:
|
||||
}
|
||||
|
||||
return ca
|
||||
}
|
||||
|
||||
type AgentReport struct {
|
||||
TimestampMillis int64 `json:"timestamp_millis"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// Scan scans data from db
|
||||
func (r *AgentReport) Scan(src any) error {
|
||||
var data []byte
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
data = v
|
||||
case string:
|
||||
data = []byte(v)
|
||||
default:
|
||||
return errors.NewInternalf(errors.CodeInternal, "tried to scan from %T instead of string or bytes", src)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, r)
|
||||
}
|
||||
|
||||
// Value serializes data to bytes for db insertion
|
||||
func (r *AgentReport) Value() (driver.Value, error) {
|
||||
if r == nil {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "agent report is nil")
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(
|
||||
err, errors.CodeInternal, "couldn't serialize agent report to JSON",
|
||||
)
|
||||
}
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
type CloudIntegrationService struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration_service,alias:cis"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Type string `bun:"type,type:text,notnull,unique:cloud_integration_id_type"`
|
||||
Config string `bun:"config,type:text"` // json serialized config
|
||||
CloudIntegrationID string `bun:"cloud_integration_id,type:text,notnull,unique:cloud_integration_id_type,references:cloud_integrations(id),on_delete:cascade"`
|
||||
}
|
||||
103
pkg/types/cloudintegrationtypes/regions.go
Normal file
103
pkg/types/cloudintegrationtypes/regions.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
CodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
|
||||
CodeMismatchCloudProvider = errors.MustNewCode("cloud_provider_mismatch")
|
||||
)
|
||||
|
||||
// List of all valid cloud regions on Amazon Web Services
|
||||
var ValidAWSRegions = map[string]bool{
|
||||
"af-south-1": true, // Africa (Cape Town).
|
||||
"ap-east-1": true, // Asia Pacific (Hong Kong).
|
||||
"ap-northeast-1": true, // Asia Pacific (Tokyo).
|
||||
"ap-northeast-2": true, // Asia Pacific (Seoul).
|
||||
"ap-northeast-3": true, // Asia Pacific (Osaka).
|
||||
"ap-south-1": true, // Asia Pacific (Mumbai).
|
||||
"ap-south-2": true, // Asia Pacific (Hyderabad).
|
||||
"ap-southeast-1": true, // Asia Pacific (Singapore).
|
||||
"ap-southeast-2": true, // Asia Pacific (Sydney).
|
||||
"ap-southeast-3": true, // Asia Pacific (Jakarta).
|
||||
"ap-southeast-4": true, // Asia Pacific (Melbourne).
|
||||
"ca-central-1": true, // Canada (Central).
|
||||
"ca-west-1": true, // Canada West (Calgary).
|
||||
"eu-central-1": true, // Europe (Frankfurt).
|
||||
"eu-central-2": true, // Europe (Zurich).
|
||||
"eu-north-1": true, // Europe (Stockholm).
|
||||
"eu-south-1": true, // Europe (Milan).
|
||||
"eu-south-2": true, // Europe (Spain).
|
||||
"eu-west-1": true, // Europe (Ireland).
|
||||
"eu-west-2": true, // Europe (London).
|
||||
"eu-west-3": true, // Europe (Paris).
|
||||
"il-central-1": true, // Israel (Tel Aviv).
|
||||
"me-central-1": true, // Middle East (UAE).
|
||||
"me-south-1": true, // Middle East (Bahrain).
|
||||
"sa-east-1": true, // South America (Sao Paulo).
|
||||
"us-east-1": true, // US East (N. Virginia).
|
||||
"us-east-2": true, // US East (Ohio).
|
||||
"us-west-1": true, // US West (N. California).
|
||||
"us-west-2": true, // US West (Oregon).
|
||||
}
|
||||
|
||||
// List of all valid cloud regions for Microsoft Azure
|
||||
var ValidAzureRegions = map[string]bool{
|
||||
"australiacentral": true, // Australia Central
|
||||
"australiacentral2": true, // Australia Central 2
|
||||
"australiaeast": true, // Australia East
|
||||
"australiasoutheast": true, // Australia Southeast
|
||||
"austriaeast": true, // Austria East
|
||||
"belgiumcentral": true, // Belgium Central
|
||||
"brazilsouth": true, // Brazil South
|
||||
"brazilsoutheast": true, // Brazil Southeast
|
||||
"canadacentral": true, // Canada Central
|
||||
"canadaeast": true, // Canada East
|
||||
"centralindia": true, // Central India
|
||||
"centralus": true, // Central US
|
||||
"chilecentral": true, // Chile Central
|
||||
"denmarkeast": true, // Denmark East
|
||||
"eastasia": true, // East Asia
|
||||
"eastus": true, // East US
|
||||
"eastus2": true, // East US 2
|
||||
"francecentral": true, // France Central
|
||||
"francesouth": true, // France South
|
||||
"germanynorth": true, // Germany North
|
||||
"germanywestcentral": true, // Germany West Central
|
||||
"indonesiacentral": true, // Indonesia Central
|
||||
"israelcentral": true, // Israel Central
|
||||
"italynorth": true, // Italy North
|
||||
"japaneast": true, // Japan East
|
||||
"japanwest": true, // Japan West
|
||||
"koreacentral": true, // Korea Central
|
||||
"koreasouth": true, // Korea South
|
||||
"malaysiawest": true, // Malaysia West
|
||||
"mexicocentral": true, // Mexico Central
|
||||
"newzealandnorth": true, // New Zealand North
|
||||
"northcentralus": true, // North Central US
|
||||
"northeurope": true, // North Europe
|
||||
"norwayeast": true, // Norway East
|
||||
"norwaywest": true, // Norway West
|
||||
"polandcentral": true, // Poland Central
|
||||
"qatarcentral": true, // Qatar Central
|
||||
"southafricanorth": true, // South Africa North
|
||||
"southafricawest": true, // South Africa West
|
||||
"southcentralus": true, // South Central US
|
||||
"southindia": true, // South India
|
||||
"southeastasia": true, // Southeast Asia
|
||||
"spaincentral": true, // Spain Central
|
||||
"swedencentral": true, // Sweden Central
|
||||
"switzerlandnorth": true, // Switzerland North
|
||||
"switzerlandwest": true, // Switzerland West
|
||||
"uaecentral": true, // UAE Central
|
||||
"uaenorth": true, // UAE North
|
||||
"uksouth": true, // UK South
|
||||
"ukwest": true, // UK West
|
||||
"westcentralus": true, // West Central US
|
||||
"westeurope": true, // West Europe
|
||||
"westindia": true, // West India
|
||||
"westus": true, // West US
|
||||
"westus2": true, // West US 2
|
||||
"westus3": true, // West US 3
|
||||
}
|
||||
263
pkg/types/cloudintegrationtypes/servicedefinitions.go
Normal file
263
pkg/types/cloudintegrationtypes/servicedefinitions.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var S3Sync = valuer.NewString("s3sync")
|
||||
|
||||
// Generic interface for cloud service definition.
|
||||
// This is implemented by AWSDefinition and AzureDefinition, which represent service definitions for AWS and Azure respectively.
|
||||
// Generics work well so far because service definitions share a similar logic.
|
||||
// We dont want to over-do generics as well, if the service definitions functionally diverge in the future consider breaking generics.
|
||||
type Definition interface {
|
||||
GetId() string
|
||||
Validate() error
|
||||
PopulateDashboardURLs(cloudProvider CloudProviderType, svcId string)
|
||||
GetIngestionStatusCheck() *IngestionStatusCheck
|
||||
GetAssets() Assets
|
||||
}
|
||||
|
||||
// AWSDefinition represents AWS Service definition, which includes collection strategy, dashboards and meta info for integration
|
||||
type AWSDefinition = ServiceDefinition[AWSCollectionStrategy]
|
||||
|
||||
// AzureDefinition represents Azure Service definition, which includes collection strategy, dashboards and meta info for integration
|
||||
type AzureDefinition = ServiceDefinition[AzureCollectionStrategy]
|
||||
|
||||
// Making AWSDefinition and AzureDefinition satisfy Definition interface, so that they can be used in a generic way
|
||||
var (
|
||||
_ Definition = &AWSDefinition{}
|
||||
_ Definition = &AzureDefinition{}
|
||||
)
|
||||
|
||||
// ServiceDefinition represents generic struct for cloud service, regardless of the cloud provider.
|
||||
// this struct must satify Definition interface.
|
||||
// StrategyT is of either AWSCollectionStrategy or AzureCollectionStrategy, depending on the cloud provider.
|
||||
type ServiceDefinition[StrategyT any] struct {
|
||||
DefinitionMetadata
|
||||
Overview string `json:"overview"` // markdown
|
||||
Assets Assets `json:"assets"`
|
||||
SupportedSignals SupportedSignals `json:"supported_signals"`
|
||||
DataCollected DataCollected `json:"data_collected"`
|
||||
IngestionStatusCheck *IngestionStatusCheck `json:"ingestion_status_check,omitempty"`
|
||||
Strategy *StrategyT `json:"telemetry_collection_strategy"`
|
||||
}
|
||||
|
||||
// Following methods are quite self explanatory, they are just to satisfy the Definition interface and provide some utility functions for service definitions.
|
||||
func (def *ServiceDefinition[StrategyT]) GetId() string {
|
||||
return def.Id
|
||||
}
|
||||
|
||||
func (def *ServiceDefinition[StrategyT]) Validate() error {
|
||||
seenDashboardIds := map[string]interface{}{}
|
||||
|
||||
if def.Strategy == nil {
|
||||
return errors.NewInternalf(errors.CodeInternal, "telemetry_collection_strategy is required")
|
||||
}
|
||||
|
||||
for _, dd := range def.Assets.Dashboards {
|
||||
if _, seen := seenDashboardIds[dd.Id]; seen {
|
||||
return errors.NewInternalf(errors.CodeInternal, "multiple dashboards found with id %s", dd.Id)
|
||||
}
|
||||
seenDashboardIds[dd.Id] = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (def *ServiceDefinition[StrategyT]) PopulateDashboardURLs(cloudProvider CloudProviderType, svcId string) {
|
||||
for i := range def.Assets.Dashboards {
|
||||
dashboardId := def.Assets.Dashboards[i].Id
|
||||
url := "/dashboard/" + GetCloudIntegrationDashboardID(cloudProvider, svcId, dashboardId)
|
||||
def.Assets.Dashboards[i].Url = url
|
||||
}
|
||||
}
|
||||
|
||||
func (def *ServiceDefinition[StrategyT]) GetIngestionStatusCheck() *IngestionStatusCheck {
|
||||
return def.IngestionStatusCheck
|
||||
}
|
||||
|
||||
func (def *ServiceDefinition[StrategyT]) GetAssets() Assets {
|
||||
return def.Assets
|
||||
}
|
||||
|
||||
// DefinitionMetadata represents service definition metadata. This is useful for showing service overview
|
||||
type DefinitionMetadata struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// IngestionStatusCheckCategory represents a category of ingestion status check. Applies for both metrics and logs.
|
||||
// A category can be "Overview" of metrics or "Enhanced" Metrics for AWS, and "Transaction" or "Capacity" metrics for Azure.
|
||||
// Each category can have multiple checks (AND logic), if all checks pass,
|
||||
// then we can be sure that data is being ingested for that category of the signal
|
||||
type IngestionStatusCheckCategory struct {
|
||||
Category string `json:"category"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Checks []*IngestionStatusCheckAttribute `json:"checks"`
|
||||
}
|
||||
|
||||
// IngestionStatusCheckAttribute represents a check or condition for ingestion status.
|
||||
// Key can be metric name or part of log message
|
||||
type IngestionStatusCheckAttribute struct {
|
||||
Key string `json:"key"` // OPTIONAL search key (metric name or log message)
|
||||
Attributes []*IngestionStatusCheckAttributeFilter `json:"attributes"`
|
||||
}
|
||||
|
||||
// IngestionStatusCheck represents combined checks for metrics and logs for a service
|
||||
type IngestionStatusCheck struct {
|
||||
Metrics []*IngestionStatusCheckCategory `json:"metrics"`
|
||||
Logs []*IngestionStatusCheckCategory `json:"logs"`
|
||||
}
|
||||
|
||||
// IngestionStatusCheckAttributeFilter represents filter for a check, which can be used to filter specific log messages or metrics with specific attributes.
|
||||
// For example, we can use it to filter logs with specific log level or metrics with specific dimensions.
|
||||
type IngestionStatusCheckAttributeFilter struct {
|
||||
Name string `json:"name"`
|
||||
Operator string `json:"operator"`
|
||||
Value string `json:"value"` // OPTIONAL
|
||||
}
|
||||
|
||||
// Assets represents the collection of dashboards
|
||||
type Assets struct {
|
||||
Dashboards []Dashboard `json:"dashboards"`
|
||||
}
|
||||
|
||||
// SupportedSignals for cloud provider's service
|
||||
type SupportedSignals struct {
|
||||
Logs bool `json:"logs"`
|
||||
Metrics bool `json:"metrics"`
|
||||
}
|
||||
|
||||
// DataCollected is curated static list of metrics and logs, this is shown as part of service overview
|
||||
type DataCollected struct {
|
||||
Logs []CollectedLogAttribute `json:"logs"`
|
||||
Metrics []CollectedMetric `json:"metrics"`
|
||||
}
|
||||
|
||||
// CollectedLogAttribute represents a log attribute that is present in all log entries for a service,
|
||||
// this is shown as part of service overview
|
||||
type CollectedLogAttribute struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// CollectedMetric represents a metric that is collected for a service, this is shown as part of service overview
|
||||
type CollectedMetric struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Unit string `json:"unit"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// AWSCollectionStrategy represents signal collection strategy for AWS services.
|
||||
// this is AWS specific.
|
||||
type AWSCollectionStrategy struct {
|
||||
Metrics *AWSMetricsStrategy `json:"aws_metrics,omitempty"`
|
||||
Logs *AWSLogsStrategy `json:"aws_logs,omitempty"`
|
||||
S3Buckets map[string][]string `json:"s3_buckets,omitempty"` // Only available in S3 Sync Service Type in AWS
|
||||
}
|
||||
|
||||
// AzureCollectionStrategy represents signal collection strategy for Azure services.
|
||||
// this is Azure specific.
|
||||
type AzureCollectionStrategy struct {
|
||||
Metrics []*AzureMetricsStrategy `json:"azure_metrics,omitempty"`
|
||||
Logs []*AzureLogsStrategy `json:"azure_logs,omitempty"`
|
||||
}
|
||||
|
||||
// AWSMetricsStrategy represents metrics collection strategy for AWS services.
|
||||
// this is AWS specific.
|
||||
type AWSMetricsStrategy struct {
|
||||
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
|
||||
StreamFilters []struct {
|
||||
// json tags here are in the shape expected by AWS API as detailed at
|
||||
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
|
||||
Namespace string `json:"Namespace"`
|
||||
MetricNames []string `json:"MetricNames,omitempty"`
|
||||
} `json:"cloudwatch_metric_stream_filters"`
|
||||
}
|
||||
|
||||
// AWSLogsStrategy represents logs collection strategy for AWS services.
|
||||
// this is AWS specific.
|
||||
type AWSLogsStrategy struct {
|
||||
Subscriptions []struct {
|
||||
// subscribe to all logs groups with specified prefix.
|
||||
// eg: `/aws/rds/`
|
||||
LogGroupNamePrefix string `json:"log_group_name_prefix"`
|
||||
|
||||
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
|
||||
// "" implies no filtering is required.
|
||||
FilterPattern string `json:"filter_pattern"`
|
||||
} `json:"cloudwatch_logs_subscriptions"`
|
||||
}
|
||||
|
||||
// AzureMetricsStrategy represents metrics collection strategy for Azure services.
|
||||
// this is Azure specific.
|
||||
type AzureMetricsStrategy struct {
|
||||
CategoryType string `json:"category_type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AzureLogsStrategy represents logs collection strategy for Azure services.
|
||||
// this is Azure specific. Even though this is similar to AzureMetricsStrategy, keeping it separate for future flexibility and clarity.
|
||||
type AzureLogsStrategy struct {
|
||||
CategoryType string `json:"category_type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Dashboard represents a dashboard definition for cloud integration.
|
||||
type Dashboard struct {
|
||||
Id string `json:"id"`
|
||||
Url string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Definition *dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
|
||||
}
|
||||
|
||||
// UTILS
|
||||
|
||||
// GetCloudIntegrationDashboardID returns the dashboard id for a cloud integration, given the cloud provider, service id, and dashboard id.
|
||||
// This is used to generate unique dashboard ids for cloud integration, and also to parse the dashboard id to get the cloud provider and service id when needed.
|
||||
func GetCloudIntegrationDashboardID(cloudProvider CloudProviderType, svcId, dashboardId string) string {
|
||||
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider, svcId, dashboardId)
|
||||
}
|
||||
|
||||
// GetDashboardsFromAssets returns the list of dashboards for the cloud provider service from definition
|
||||
func GetDashboardsFromAssets(
|
||||
svcId string,
|
||||
orgID valuer.UUID,
|
||||
cloudProvider CloudProviderType,
|
||||
createdAt *time.Time,
|
||||
assets Assets,
|
||||
) []*dashboardtypes.Dashboard {
|
||||
dashboards := make([]*dashboardtypes.Dashboard, 0)
|
||||
|
||||
for _, d := range assets.Dashboards {
|
||||
author := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
dashboards = append(dashboards, &dashboardtypes.Dashboard{
|
||||
ID: GetCloudIntegrationDashboardID(cloudProvider, svcId, d.Id),
|
||||
Locked: true,
|
||||
OrgID: orgID,
|
||||
Data: *d.Definition,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: *createdAt,
|
||||
UpdatedAt: *createdAt,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: author,
|
||||
UpdatedBy: author,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return dashboards
|
||||
}
|
||||
42
pkg/types/cloudintegrationtypes/store.go
Normal file
42
pkg/types/cloudintegrationtypes/store.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CloudIntegrationAccountStore interface {
|
||||
ListConnected(ctx context.Context, orgId string, provider string) ([]CloudIntegration, error)
|
||||
|
||||
Get(ctx context.Context, orgId string, provider string, id string) (*CloudIntegration, error)
|
||||
|
||||
GetConnectedCloudAccount(ctx context.Context, orgId, provider string, accountID string) (*CloudIntegration, error)
|
||||
|
||||
// Insert an account or update it by (cloudProvider, id)
|
||||
// for specified non-empty fields
|
||||
Upsert(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
provider string,
|
||||
id *string,
|
||||
config []byte,
|
||||
accountId *string,
|
||||
agentReport *AgentReport,
|
||||
removedAt *time.Time,
|
||||
) (*CloudIntegration, error)
|
||||
}
|
||||
|
||||
type CloudIntegrationServiceStore interface {
|
||||
Get(ctx context.Context, orgID, cloudAccountId, serviceType string) ([]byte, error)
|
||||
|
||||
Upsert(
|
||||
ctx context.Context,
|
||||
orgID,
|
||||
cloudProvider,
|
||||
cloudAccountId,
|
||||
serviceId string,
|
||||
config []byte,
|
||||
) ([]byte, error)
|
||||
|
||||
GetAllForAccount(ctx context.Context, orgID, cloudAccountId string) (map[string][]byte, error)
|
||||
}
|
||||
@@ -301,7 +301,6 @@ type ListMetricsParams struct {
|
||||
End *int64 `query:"end"`
|
||||
Limit int `query:"limit"`
|
||||
Search string `query:"searchText"`
|
||||
Source string `query:"source"`
|
||||
}
|
||||
|
||||
// Validate ensures ListMetricsParams contains acceptable values.
|
||||
|
||||
@@ -447,7 +447,7 @@ type MetricAggregation struct {
|
||||
// space aggregation to apply to the query
|
||||
SpaceAggregation metrictypes.SpaceAggregation `json:"spaceAggregation"`
|
||||
// param for space aggregation if needed
|
||||
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty"`
|
||||
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam"`
|
||||
// table hints to use for the query
|
||||
TableHints *metrictypes.MetricTableHints `json:"-"`
|
||||
// value filter to apply to the query
|
||||
|
||||
@@ -15,7 +15,6 @@ pytest_plugins = [
|
||||
"fixtures.logs",
|
||||
"fixtures.traces",
|
||||
"fixtures.metrics",
|
||||
"fixtures.meter",
|
||||
"fixtures.driver",
|
||||
"fixtures.idp",
|
||||
"fixtures.idputils",
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Generator, List
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
|
||||
|
||||
class MeterSample:
|
||||
temporality: str
|
||||
metric_name: str
|
||||
description: str
|
||||
unit: str
|
||||
type: str
|
||||
is_monotonic: bool
|
||||
labels: str
|
||||
fingerprint: np.uint64
|
||||
unix_milli: np.int64
|
||||
value: np.float64
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metric_name: str,
|
||||
labels: dict[str, str],
|
||||
timestamp: datetime,
|
||||
value: float,
|
||||
temporality: str = "Delta",
|
||||
description: str = "",
|
||||
unit: str = "",
|
||||
type_: str = "Sum",
|
||||
is_monotonic: bool = True,
|
||||
) -> None:
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.description = description
|
||||
self.unit = unit
|
||||
self.type = type_
|
||||
self.is_monotonic = is_monotonic
|
||||
self.labels = json.dumps(labels, separators=(",", ":"))
|
||||
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
|
||||
self.value = np.float64(value)
|
||||
|
||||
fingerprint_str = metric_name + self.labels
|
||||
self.fingerprint = np.uint64(
|
||||
int(hashlib.md5(fingerprint_str.encode()).hexdigest()[:16], 16)
|
||||
)
|
||||
|
||||
def to_samples_row(self) -> list:
|
||||
return [
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.description,
|
||||
self.unit,
|
||||
self.type,
|
||||
self.is_monotonic,
|
||||
self.labels,
|
||||
self.fingerprint,
|
||||
self.unix_milli,
|
||||
self.value,
|
||||
]
|
||||
|
||||
|
||||
def make_meter_samples(
|
||||
metric_name: str,
|
||||
labels: dict[str, str],
|
||||
now: datetime,
|
||||
count: int = 60,
|
||||
base_value: float = 100.0,
|
||||
**kwargs,
|
||||
) -> List[MeterSample]:
|
||||
samples = []
|
||||
for i in range(count):
|
||||
ts = now - timedelta(minutes=count - i)
|
||||
samples.append(
|
||||
MeterSample(
|
||||
metric_name=metric_name,
|
||||
labels=labels,
|
||||
timestamp=ts,
|
||||
value=base_value + i,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
return samples
|
||||
|
||||
|
||||
@pytest.fixture(name="insert_meter_samples", scope="function")
|
||||
def insert_meter_samples(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
) -> Generator[Callable[[List[MeterSample]], None], Any, None]:
|
||||
def _insert_meter_samples(samples: List[MeterSample]) -> None:
|
||||
if len(samples) == 0:
|
||||
return
|
||||
|
||||
clickhouse.conn.insert(
|
||||
database="signoz_meter",
|
||||
table="distributed_samples",
|
||||
column_names=[
|
||||
"temporality",
|
||||
"metric_name",
|
||||
"description",
|
||||
"unit",
|
||||
"type",
|
||||
"is_monotonic",
|
||||
"labels",
|
||||
"fingerprint",
|
||||
"unix_milli",
|
||||
"value",
|
||||
],
|
||||
data=[s.to_samples_row() for s in samples],
|
||||
)
|
||||
|
||||
yield _insert_meter_samples
|
||||
|
||||
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
for table in ["samples", "samples_agg_1d"]:
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_meter.{table} ON CLUSTER '{cluster}' SYNC"
|
||||
)
|
||||
@@ -54,7 +54,6 @@ def build_builder_query(
|
||||
*,
|
||||
comparisonSpaceAggregationParam: Optional[Dict] = None,
|
||||
temporality: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
step_interval: int = DEFAULT_STEP_INTERVAL,
|
||||
group_by: Optional[List[str]] = None,
|
||||
filter_expression: Optional[str] = None,
|
||||
@@ -74,14 +73,10 @@ def build_builder_query(
|
||||
"stepInterval": step_interval,
|
||||
"disabled": disabled,
|
||||
}
|
||||
if source:
|
||||
spec["source"] = source
|
||||
if temporality:
|
||||
spec["aggregations"][0]["temporality"] = temporality
|
||||
if comparisonSpaceAggregationParam:
|
||||
spec["aggregations"][0][
|
||||
"comparisonSpaceAggregationParam"
|
||||
] = comparisonSpaceAggregationParam
|
||||
spec["aggregations"][0]["comparisonSpaceAggregationParam"] = comparisonSpaceAggregationParam
|
||||
if group_by:
|
||||
spec["groupBy"] = [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Look at the histogram_data_1h.jsonl file for the relevant data
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
@@ -21,7 +22,6 @@ from fixtures.utils import get_testdata_file_path
|
||||
|
||||
FILE = get_testdata_file_path("histogram_data_1h.jsonl")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"threshold, operator, first_value, last_value",
|
||||
[
|
||||
@@ -29,22 +29,12 @@ FILE = get_testdata_file_path("histogram_data_1h.jsonl")
|
||||
(100, "<=", 1.1, 6.9),
|
||||
(7500, "<=", 16.75, 74.75),
|
||||
(8000, "<=", 17, 75),
|
||||
(
|
||||
80000,
|
||||
"<=",
|
||||
17,
|
||||
75,
|
||||
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(80000, "<=", 17, 75), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(1000, ">", 7, 7),
|
||||
(100, ">", 16.9, 69.1),
|
||||
(7500, ">", 1.25, 1.25),
|
||||
(8000, ">", 1, 1),
|
||||
(
|
||||
80000,
|
||||
">",
|
||||
1,
|
||||
1,
|
||||
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(80000, ">", 1, 1), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
],
|
||||
)
|
||||
def test_histogram_count_for_one_endpoint(
|
||||
@@ -75,7 +65,10 @@ def test_histogram_count_for_one_endpoint(
|
||||
metric_name,
|
||||
"increase",
|
||||
"count",
|
||||
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
|
||||
comparisonSpaceAggregationParam={
|
||||
"threshold": threshold,
|
||||
"operator": operator
|
||||
},
|
||||
filter_expression='endpoint = "/health"',
|
||||
)
|
||||
|
||||
@@ -88,7 +81,6 @@ def test_histogram_count_for_one_endpoint(
|
||||
assert result_values[0]["value"] == first_value
|
||||
assert result_values[-1]["value"] == last_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"threshold, operator, first_value, last_value",
|
||||
[
|
||||
@@ -96,22 +88,12 @@ def test_histogram_count_for_one_endpoint(
|
||||
(100, "<=", 2.2, 13.8),
|
||||
(7500, "<=", 33.5, 149.5),
|
||||
(8000, "<=", 34, 150),
|
||||
(
|
||||
80000,
|
||||
"<=",
|
||||
34,
|
||||
150,
|
||||
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(80000, "<=", 34, 150), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(1000, ">", 14, 14),
|
||||
(100, ">", 33.8, 138.2),
|
||||
(7500, ">", 2.5, 2.5),
|
||||
(8000, ">", 2, 2),
|
||||
(
|
||||
80000,
|
||||
">",
|
||||
2,
|
||||
2,
|
||||
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(80000, ">", 2, 2), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
],
|
||||
)
|
||||
def test_histogram_count_for_one_service(
|
||||
@@ -142,7 +124,10 @@ def test_histogram_count_for_one_service(
|
||||
metric_name,
|
||||
"increase",
|
||||
"count",
|
||||
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
|
||||
comparisonSpaceAggregationParam={
|
||||
"threshold": threshold,
|
||||
"operator": operator
|
||||
},
|
||||
filter_expression='service = "api"',
|
||||
)
|
||||
|
||||
@@ -155,7 +140,6 @@ def test_histogram_count_for_one_service(
|
||||
assert result_values[0]["value"] == first_value
|
||||
assert result_values[-1]["value"] == last_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"threshold, operator, zeroth_value, first_value, last_value",
|
||||
[
|
||||
@@ -163,24 +147,12 @@ def test_histogram_count_for_one_service(
|
||||
(100, "<=", 1234.5, 1.1, 6.9),
|
||||
(7500, "<=", 12345, 16.75, 74.75),
|
||||
(8000, "<=", 12345, 17, 75),
|
||||
(
|
||||
80000,
|
||||
"<=",
|
||||
12345,
|
||||
17,
|
||||
75,
|
||||
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(80000, "<=", 12345, 17, 75), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(1000, ">", 0, 7, 7),
|
||||
(100, ">", 11110.5, 16.9, 69.1),
|
||||
(7500, ">", 0, 1.25, 1.25),
|
||||
(8000, ">", 0, 1, 1),
|
||||
(
|
||||
80000,
|
||||
">",
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
(80000, ">", 0, 1, 1), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
|
||||
],
|
||||
)
|
||||
def test_histogram_count_for_delta_service(
|
||||
@@ -212,7 +184,10 @@ def test_histogram_count_for_delta_service(
|
||||
metric_name,
|
||||
"increase",
|
||||
"count",
|
||||
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
|
||||
comparisonSpaceAggregationParam={
|
||||
"threshold": threshold,
|
||||
"operator": operator
|
||||
},
|
||||
filter_expression='service = "web"',
|
||||
)
|
||||
|
||||
@@ -221,16 +196,11 @@ def test_histogram_count_for_delta_service(
|
||||
|
||||
data = response.json()
|
||||
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
|
||||
assert (
|
||||
len(result_values) == 60
|
||||
) ## in delta, the value at 10:01 will also be reported
|
||||
assert len(result_values) == 60 ## in delta, the value at 10:01 will also be reported
|
||||
assert result_values[0]["value"] == zeroth_value
|
||||
assert (
|
||||
result_values[1]["value"] == first_value
|
||||
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
|
||||
assert result_values[1]["value"] == first_value ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
|
||||
assert result_values[-1]["value"] == last_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"threshold, operator, zeroth_value, first_value, last_value",
|
||||
[
|
||||
@@ -275,7 +245,10 @@ def test_histogram_count_for_all_services(
|
||||
metric_name,
|
||||
"increase",
|
||||
"count",
|
||||
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
|
||||
comparisonSpaceAggregationParam={
|
||||
"threshold": threshold,
|
||||
"operator": operator
|
||||
},
|
||||
## no services filter, this tests for multitemporality handling as well
|
||||
)
|
||||
|
||||
@@ -284,16 +257,11 @@ def test_histogram_count_for_all_services(
|
||||
|
||||
data = response.json()
|
||||
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
|
||||
assert (
|
||||
len(result_values) == 60
|
||||
) ## in delta, the value at 10:01 will also be reported
|
||||
assert len(result_values) == 60 ## in delta, the value at 10:01 will also be reported
|
||||
assert result_values[0]["value"] == zeroth_value
|
||||
assert (
|
||||
result_values[1]["value"] == first_value
|
||||
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
|
||||
assert result_values[1]["value"] == first_value ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
|
||||
assert result_values[-1]["value"] == last_value
|
||||
|
||||
|
||||
def test_histogram_count_no_param(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
@@ -340,26 +308,8 @@ def test_histogram_count_no_param(
|
||||
set(le_buckets.keys()) == expected_buckets
|
||||
), f"Expected endpoints {expected_buckets}, got {set(le_buckets.keys())}"
|
||||
|
||||
first_values = {
|
||||
"1000": 33,
|
||||
"1500": 36,
|
||||
"2000": 39,
|
||||
"4000": 42,
|
||||
"5000": 45,
|
||||
"6000": 48,
|
||||
"8000": 51,
|
||||
"+Inf": 54,
|
||||
}
|
||||
last_values = {
|
||||
"1000": 207,
|
||||
"1500": 210,
|
||||
"2000": 213,
|
||||
"4000": 216,
|
||||
"5000": 219,
|
||||
"6000": 222,
|
||||
"8000": 225,
|
||||
"+Inf": 228,
|
||||
}
|
||||
first_values = {"1000": 33, "1500": 36, "2000": 39, "4000": 42, "5000": 45, "6000": 48, "8000": 51, "+Inf": 54}
|
||||
last_values = {"1000": 207, "1500": 210, "2000": 213, "4000": 216, "5000": 219, "6000": 222, "8000": 225, "+Inf": 228}
|
||||
for le, values in le_buckets.items():
|
||||
assert len(values) == 60
|
||||
|
||||
@@ -368,7 +318,5 @@ def test_histogram_count_no_param(
|
||||
v["value"] >= 0
|
||||
), f"Count for {le} should not be negative: {v['value']}"
|
||||
assert values[0]["value"] == 12345
|
||||
assert (
|
||||
values[1]["value"] == first_values[le]
|
||||
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
|
||||
assert values[-1]["value"] == last_values[le]
|
||||
assert values[1]["value"] == first_values[le] ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
|
||||
assert values[-1]["value"] == last_values[le]
|
||||
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
@@ -9,6 +10,7 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import (
|
||||
build_builder_query,
|
||||
get_all_series,
|
||||
get_series_values,
|
||||
make_query_request,
|
||||
)
|
||||
@@ -16,7 +18,6 @@ from fixtures.utils import get_testdata_file_path
|
||||
|
||||
FILE = get_testdata_file_path("gauge_data_1h.jsonl")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time_agg, space_agg, service, num_elements, start_val, first_val, twentieth_min_val, after_twentieth_min_val",
|
||||
[
|
||||
@@ -49,7 +50,7 @@ def test_for_one_service(
|
||||
start_val: float,
|
||||
first_val: float,
|
||||
twentieth_min_val: float,
|
||||
after_twentieth_min_val: float, ## web service has a gap of 10 mins after the 20th minute
|
||||
after_twentieth_min_val: float ## web service has a gap of 10 mins after the 20th minute
|
||||
) -> None:
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
@@ -83,7 +84,6 @@ def test_for_one_service(
|
||||
assert result_values[19]["value"] == twentieth_min_val
|
||||
assert result_values[20]["value"] == after_twentieth_min_val
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time_agg, space_agg, start_val, first_val, twentieth_min_val, twenty_first_min_val, thirty_first_min_val",
|
||||
[
|
||||
@@ -105,8 +105,8 @@ def test_for_multiple_aggregations(
|
||||
start_val: float,
|
||||
first_val: float,
|
||||
twentieth_min_val: float,
|
||||
twenty_first_min_val: float, ## web service has a gap of 10 mins after the 20th minute
|
||||
thirty_first_min_val: float,
|
||||
twenty_first_min_val: float, ## web service has a gap of 10 mins after the 20th minute
|
||||
thirty_first_min_val: float
|
||||
) -> None:
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
@@ -138,4 +138,4 @@ def test_for_multiple_aggregations(
|
||||
assert result_values[1]["value"] == first_val
|
||||
assert result_values[19]["value"] == twentieth_min_val
|
||||
assert result_values[20]["value"] == twenty_first_min_val
|
||||
assert result_values[30]["value"] == thirty_first_min_val
|
||||
assert result_values[30]["value"] == thirty_first_min_val
|
||||
@@ -53,9 +53,7 @@ def test_rate_with_steady_values_and_reset(
|
||||
|
||||
data = response.json()
|
||||
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
|
||||
assert (
|
||||
len(result_values) == 60
|
||||
) ## total 61 minutes covered, and 30th minute is missing
|
||||
assert len(result_values) == 60 ## total 61 minutes covered, and 30th minute is missing
|
||||
assert (
|
||||
result_values[30]["value"] == 0.0333
|
||||
) # reset happens and [30] is for 31st minute. 2/60 cuz delta divides by step interval
|
||||
@@ -63,7 +61,9 @@ def test_rate_with_steady_values_and_reset(
|
||||
result_values[31]["value"] == 0.133
|
||||
) # i.e 8/60 i.e 31st to 32nd minute changes
|
||||
count_of_steady_rate = sum(1 for v in result_values if v["value"] == 0.0833)
|
||||
assert count_of_steady_rate == 58 # 1 reset + 1 high rate are excluded
|
||||
assert (
|
||||
count_of_steady_rate == 58
|
||||
) # 1 reset + 1 high rate are excluded
|
||||
# All rates should be non-negative (stale periods = 0 rate)
|
||||
for v in result_values:
|
||||
assert v["value"] >= 0, f"Rate should not be negative: {v['value']}"
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.meter import MeterSample, make_meter_samples
|
||||
from fixtures.querier import (
|
||||
build_builder_query,
|
||||
get_series_values,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
|
||||
def test_query_range_cost_meter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_meter_samples: Callable[[List[MeterSample]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
metric_name = "signoz_cost_test_query_range"
|
||||
labels = {"service": "test-service", "environment": "production"}
|
||||
|
||||
samples = make_meter_samples(
|
||||
metric_name,
|
||||
labels,
|
||||
now,
|
||||
count=60,
|
||||
base_value=100.0,
|
||||
temporality="Delta",
|
||||
type_="Sum",
|
||||
is_monotonic=True,
|
||||
)
|
||||
insert_meter_samples(samples)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_builder_query(
|
||||
"A",
|
||||
metric_name,
|
||||
"sum",
|
||||
"sum",
|
||||
source="meter",
|
||||
temporality="delta",
|
||||
)
|
||||
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
data = response.json()
|
||||
result_values = get_series_values(data, "A")
|
||||
assert len(result_values) > 0, f"Expected non-empty results, got: {data}"
|
||||
|
||||
for val in result_values:
|
||||
assert val["value"] >= 0, f"Expected non-negative value, got: {val['value']}"
|
||||
|
||||
|
||||
def test_list_meter_metric_names(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_meter_samples: Callable[[List[MeterSample]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
metric_name = "cost_test_list_metrics"
|
||||
labels = {"service": "billing-service"}
|
||||
|
||||
samples = make_meter_samples(
|
||||
metric_name,
|
||||
labels,
|
||||
now,
|
||||
count=5,
|
||||
base_value=50.0,
|
||||
temporality="Delta",
|
||||
type_="Sum",
|
||||
is_monotonic=True,
|
||||
)
|
||||
insert_meter_samples(samples)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/metrics"),
|
||||
params={
|
||||
"start": start_ms,
|
||||
"end": end_ms,
|
||||
"limit": 100,
|
||||
"searchText": "cost_test_list",
|
||||
"source": "meter",
|
||||
},
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
data = response.json()
|
||||
metrics = data.get("data", {}).get("metrics", [])
|
||||
metric_names = [m["metricName"] for m in metrics]
|
||||
assert (
|
||||
metric_name in metric_names
|
||||
), f"Expected {metric_name} in metric names, got: {metric_names}"
|
||||
Reference in New Issue
Block a user