mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-02 12:02:09 +00:00
Compare commits
5 Commits
refactor/c
...
metric-exp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a98eecfc | ||
|
|
b3c08ec417 | ||
|
|
8737030580 | ||
|
|
c21e463e55 | ||
|
|
2091e63195 |
@@ -6167,6 +6167,10 @@ paths:
|
||||
name: searchText
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: source
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
||||
@@ -3451,6 +3451,11 @@ export type ListMetricsParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
searchText?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type ListMetrics200 = {
|
||||
|
||||
@@ -60,11 +60,30 @@
|
||||
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-aggregation-section-content,
|
||||
.metrics-container {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
@@ -102,6 +121,10 @@
|
||||
.qb-elements-container {
|
||||
margin-left: 0px;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
@@ -333,28 +356,7 @@
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
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;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,10 +464,21 @@
|
||||
|
||||
.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-aggregation-section-content,
|
||||
.metrics-container {
|
||||
&::before {
|
||||
border-left: 6px dotted var(--bg-vanilla-300);
|
||||
}
|
||||
@@ -529,18 +542,6 @@
|
||||
|
||||
.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,6 +207,7 @@ 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,14 +1,13 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { MetricNameSelector } 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';
|
||||
@@ -44,21 +43,12 @@ 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,
|
||||
@@ -164,12 +154,10 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
/>
|
||||
)}
|
||||
|
||||
<AggregatorFilter
|
||||
onChange={handleAggregatorAttributeChange}
|
||||
<MetricNameSelector
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
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,6 +43,7 @@ jest.mock(
|
||||
);
|
||||
jest.mock('container/QueryBuilder/filters', () => ({
|
||||
AggregatorFilter: (): JSX.Element => <div />,
|
||||
MetricNameSelector: (): JSX.Element => <div />,
|
||||
}));
|
||||
// Mock hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
|
||||
@@ -177,7 +177,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -225,7 +225,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: MeterAggregateOperator.COUNT,
|
||||
timeAggregation: MeterAggregateOperator.AVG,
|
||||
spaceAggregation: MeterAggregateOperator.SUM,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
|
||||
@@ -441,7 +441,7 @@ describe('Footer utils', () => {
|
||||
reduceTo: undefined,
|
||||
spaceAggregation: 'sum',
|
||||
temporality: undefined,
|
||||
timeAggregation: 'count',
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -115,27 +116,34 @@ 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">
|
||||
{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>
|
||||
))}
|
||||
{!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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Typography } from 'antd';
|
||||
import { Empty } from 'antd/lib';
|
||||
|
||||
export default function EmptyMetricsSearch(): JSX.Element {
|
||||
interface EmptyMetricsSearchProps {
|
||||
hasQueryResult?: boolean;
|
||||
}
|
||||
|
||||
export default function EmptyMetricsSearch({
|
||||
hasQueryResult,
|
||||
}: EmptyMetricsSearchProps): JSX.Element {
|
||||
return (
|
||||
<div className="empty-metrics-search">
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Title level={5}>
|
||||
Please build and run a valid query to see the result
|
||||
{hasQueryResult
|
||||
? 'No data'
|
||||
: 'Select a metric and run a query to see the results'}
|
||||
</Typography.Title>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ function Explorer(): JSX.Element {
|
||||
!isMetricUnitsLoading &&
|
||||
!isMetricUnitsError &&
|
||||
units.length > 0 &&
|
||||
units.every((unit) => unit && unit === units[0]),
|
||||
units.every((unit) => unit === units[0]),
|
||||
[units, isMetricUnitsLoading, isMetricUnitsError],
|
||||
);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ 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,
|
||||
@@ -209,7 +210,7 @@ function TimeSeries({
|
||||
{showSaveUnitButton && (
|
||||
<div className="save-unit-container">
|
||||
<Typography.Text>
|
||||
Save the selected unit for this metric?
|
||||
Set the selected unit as the metric unit?
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -229,64 +230,71 @@ function TimeSeries({
|
||||
'time-series-container': changeLayoutForOneChartPerQuery,
|
||||
})}
|
||||
>
|
||||
{responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
|
||||
{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;
|
||||
|
||||
// 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>
|
||||
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>
|
||||
);
|
||||
})}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as useOptionsMenuHooks from 'container/OptionsMenu';
|
||||
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
|
||||
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -157,26 +157,6 @@ 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' }),
|
||||
@@ -241,20 +221,46 @@ describe('Explorer', () => {
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide y axis unit selector for multiple metrics with different units', () => {
|
||||
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);
|
||||
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
metrics: [
|
||||
{ ...MOCK_METRIC_METADATA, unit: 'seconds' },
|
||||
{ ...MOCK_METRIC_METADATA, unit: 'bytes' },
|
||||
],
|
||||
});
|
||||
|
||||
(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();
|
||||
});
|
||||
|
||||
@@ -327,4 +333,53 @@ 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, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } 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> = {},
|
||||
): RenderResult {
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TimeSeries
|
||||
showOneChartPerQuery={false}
|
||||
@@ -84,45 +84,57 @@ 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', () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTimeSeries({
|
||||
renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [undefined, undefined],
|
||||
});
|
||||
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.findByText('This metric does not have a unit'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'no unit warning' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on warning icon tooltip should open metric details modal', async () => {
|
||||
it('warning tooltip shows metric details link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTimeSeries({
|
||||
renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
yAxisUnit: 'seconds',
|
||||
});
|
||||
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
const alertIcon = screen.getByRole('img', { name: 'no unit warning' });
|
||||
await user.hover(alertIcon);
|
||||
|
||||
const metricDetailsLink = await screen.findByText('metric details');
|
||||
user.click(metricDetailsLink);
|
||||
|
||||
waitFor(() =>
|
||||
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
|
||||
);
|
||||
expect(await screen.findByText('metric details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Save unit button when metric had no unit but one is selected', async () => {
|
||||
const { findByText, getByRole } = renderTimeSeries({
|
||||
it('shows save unit prompt with enabled button when metric has no unit and a unit is selected', async () => {
|
||||
renderTimeSeries({
|
||||
metricUnits: [undefined],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
@@ -131,38 +143,10 @@ describe('TimeSeries', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
await findByText('Save the selected unit for this metric?'),
|
||||
await screen.findByText('Set the selected unit as the metric unit?'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const yesButton = getByRole('button', { name: 'Yes' });
|
||||
expect(yesButton).toBeInTheDocument();
|
||||
const yesButton = screen.getByRole('button', { name: 'Yes' });
|
||||
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,4 +139,14 @@ 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 { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { MetricNameSelector } 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>
|
||||
<AggregatorFilter
|
||||
<MetricNameSelector
|
||||
defaultValue={searchText ?? ''}
|
||||
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
|
||||
onSelect={handleSetMetricName}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } 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';
|
||||
@@ -23,27 +24,31 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
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('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('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',
|
||||
@@ -123,6 +128,24 @@ 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}>
|
||||
@@ -137,8 +160,12 @@ describe('QueryBuilder', () => {
|
||||
|
||||
expect(screen.getByText('From')).toBeInTheDocument();
|
||||
|
||||
const selectButton = screen.getByTestId('select-metric-button');
|
||||
await user.click(selectButton);
|
||||
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);
|
||||
|
||||
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Button,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Input,
|
||||
Menu,
|
||||
Popover,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
@@ -14,148 +14,49 @@ 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 { useNotifications } from 'hooks/useNotifications';
|
||||
import { Compass, Copy, Search } from 'lucide-react';
|
||||
import { Check, Copy, Info, Search, SquareArrowOutUpRight } 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 {
|
||||
AllAttributesEmptyTextProps,
|
||||
AllAttributesProps,
|
||||
AllAttributesValueProps,
|
||||
} from './types';
|
||||
AllAttributesEmptyText,
|
||||
AllAttributesValue,
|
||||
} from './AllAttributesValue';
|
||||
import { AllAttributesProps } from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
|
||||
const ALL_ATTRIBUTES_KEY = 'all-attributes';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const COPY_FEEDBACK_DURATION_MS = 1500;
|
||||
|
||||
function AllAttributes({
|
||||
metricName,
|
||||
metricType,
|
||||
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,
|
||||
});
|
||||
} = useGetMetricAttributes(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const attributes = useMemo(() => attributesData?.data.attributes ?? [], [
|
||||
attributesData,
|
||||
@@ -164,12 +65,14 @@ function AllAttributes({
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const goToMetricsExplorerwithAppliedSpaceAggregation = useCallback(
|
||||
(groupBy: string) => {
|
||||
(groupBy: string, valueCount?: number) => {
|
||||
const limit = valueCount && valueCount > 250 ? 100 : undefined;
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metricType,
|
||||
undefined,
|
||||
groupBy,
|
||||
limit,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -216,6 +119,28 @@ function AllAttributes({
|
||||
[metricName, metricType, 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],
|
||||
);
|
||||
|
||||
const filteredAttributes = useMemo(
|
||||
() =>
|
||||
attributes.filter(
|
||||
@@ -254,21 +179,57 @@ function AllAttributes({
|
||||
width: 50,
|
||||
align: 'left',
|
||||
className: 'metric-metadata-key',
|
||||
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>
|
||||
),
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Value',
|
||||
@@ -291,7 +252,9 @@ function AllAttributes({
|
||||
],
|
||||
[
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
goToMetricsExplorerwithAppliedSpaceAggregation,
|
||||
handleKeyMenuItemClick,
|
||||
keyPopoverOpen,
|
||||
copiedKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -300,7 +263,12 @@ function AllAttributes({
|
||||
{
|
||||
label: (
|
||||
<div className="metrics-accordion-header">
|
||||
<Typography.Text>All Attributes</Typography.Text>
|
||||
<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>
|
||||
<Input
|
||||
className="all-attributes-search-input"
|
||||
placeholder="Search"
|
||||
@@ -329,7 +297,9 @@ function AllAttributes({
|
||||
className="metrics-accordion-content all-attributes-content"
|
||||
scroll={{ y: 600 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
emptyText: isLoadingAttributes ? (
|
||||
' '
|
||||
) : (
|
||||
<AllAttributesEmptyText
|
||||
isErrorAttributes={isErrorAttributes}
|
||||
refetchAttributes={refetchAttributes}
|
||||
@@ -350,14 +320,6 @@ function AllAttributes({
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoadingAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Input, Menu, Popover, Tooltip, Typography } from 'antd';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
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 { notifications } = useNotifications();
|
||||
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);
|
||||
notifications.success({
|
||||
message: 'Value copied!',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setAttributePopoverKey(null);
|
||||
},
|
||||
[
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
filterKey,
|
||||
handleCopyWithFeedback,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
|
||||
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) => (
|
||||
<Popover
|
||||
key={attribute}
|
||||
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 key={attribute} type="text">
|
||||
<Typography.Text>{attribute}</Typography.Text>
|
||||
</Button>
|
||||
</Popover>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { Button, Spin, Tooltip, Typography } from 'antd';
|
||||
import { useGetMetricHighlights } from 'api/generated/services/metrics';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
@@ -39,17 +39,6 @@ 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">
|
||||
@@ -89,32 +78,41 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Spin, 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}
|
||||
disabled={isUpdatingMetricsMetadata || isLoadingMetricMetadata}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<Typography.Text>Edit</Typography.Text>
|
||||
@@ -345,6 +345,7 @@ function Metadata({
|
||||
isEditing,
|
||||
isErrorMetricMetadata,
|
||||
isUpdatingMetricsMetadata,
|
||||
isLoadingMetricMetadata,
|
||||
cancelEdit,
|
||||
handleSave,
|
||||
]);
|
||||
@@ -359,7 +360,11 @@ function Metadata({
|
||||
</div>
|
||||
),
|
||||
key: 'metric-metadata',
|
||||
children: isErrorMetricMetadata ? (
|
||||
children: isLoadingMetricMetadata ? (
|
||||
<div className="metrics-accordion-loading-state">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : isErrorMetricMetadata ? (
|
||||
<div className="metric-metadata-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchMetricMetadata}
|
||||
@@ -381,20 +386,13 @@ 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,6 +52,13 @@
|
||||
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;
|
||||
@@ -120,12 +127,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-metadata-skeleton-container {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
.all-attributes-skeleton-container {
|
||||
height: 600px;
|
||||
.metrics-accordion-loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.metrics-accordion {
|
||||
@@ -153,6 +159,18 @@
|
||||
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);
|
||||
@@ -186,6 +204,7 @@
|
||||
.all-attributes-key {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.ant-btn {
|
||||
.ant-typography:first-child {
|
||||
font-family: 'Geist Mono';
|
||||
@@ -193,17 +212,15 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,10 +276,8 @@
|
||||
}
|
||||
|
||||
.metric-metadata-key {
|
||||
cursor: pointer;
|
||||
padding-left: 10px;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
.field-renderer-container {
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -448,3 +463,138 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-out {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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';
|
||||
@@ -29,6 +33,9 @@ function MetricDetails({
|
||||
}: MetricDetailsProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const {
|
||||
data: metricMetadataResponse,
|
||||
@@ -100,6 +107,21 @@ 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%"
|
||||
@@ -137,7 +159,7 @@ function MetricDetails({
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
onClose={handleDrawerClose}
|
||||
open={isOpen}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
@@ -157,7 +179,12 @@ function MetricDetails({
|
||||
isLoadingMetricMetadata={isLoadingMetricMetadata}
|
||||
refetchMetricMetadata={refetchMetricMetadata}
|
||||
/>
|
||||
<AllAttributes metricName={metricName} metricType={metadata?.type} />
|
||||
<AllAttributes
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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, { AllAttributesValue } from '../AllAttributes';
|
||||
import AllAttributes from '../AllAttributes';
|
||||
import { AllAttributesValue } from '../AllAttributesValue';
|
||||
import { getMockMetricAttributesData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -15,17 +14,6 @@ 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,
|
||||
@@ -34,12 +22,13 @@ const useGetMetricAttributesMock = jest.spyOn(
|
||||
|
||||
describe('AllAttributes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders attributes section with title', () => {
|
||||
it('renders attribute keys, values, and value counts from API data', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
@@ -47,39 +36,13 @@ 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({
|
||||
@@ -100,7 +63,7 @@ describe('AllAttributes', () => {
|
||||
expect(screen.getByText('No attributes found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
|
||||
it('clicking on an attribute key shows popover with Open in Metric Explorer option', async () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
@@ -108,7 +71,8 @@ describe('AllAttributes', () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('attribute1'));
|
||||
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
|
||||
expect(screen.getByText('Open in Metric Explorer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copy Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters attributes based on search input', async () => {
|
||||
@@ -123,26 +87,66 @@ 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();
|
||||
|
||||
it('renders all attribute values', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads more attributes when show more button is clicked', async () => {
|
||||
it('shows All values button when there are more than 5 values', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -153,58 +157,59 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('value6')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Show More'));
|
||||
expect(screen.getByText('value6')).toBeInTheDocument();
|
||||
expect(screen.getByText('All values (6)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render show more button when there are no more attributes to show', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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',
|
||||
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)'));
|
||||
|
||||
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',
|
||||
];
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={values}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('All values (7)'));
|
||||
await userEvent.type(screen.getByPlaceholderText('Search values'), 'golf');
|
||||
|
||||
expect(screen.getByText('golf-target')).toBeInTheDocument();
|
||||
expect(screen.queryByText('fig-special')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Highlights', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state when data is loading', () => {
|
||||
it('should show labels and loading text but not stale data values while loading', () => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(
|
||||
getMockMetricHighlightsData(
|
||||
{},
|
||||
@@ -60,8 +60,19 @@ 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.getByTestId('metric-highlights-loading-state'),
|
||||
).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,6 +324,22 @@ 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,6 +24,13 @@ 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(),
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface MetadataProps {
|
||||
export interface AllAttributesProps {
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
export interface AllAttributesValueProps {
|
||||
|
||||
@@ -87,6 +87,7 @@ export function getMetricDetailsQuery(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
filter?: { key: string; value: string },
|
||||
groupBy?: string,
|
||||
limit?: number,
|
||||
): Query {
|
||||
let timeAggregation;
|
||||
let spaceAggregation;
|
||||
@@ -170,6 +171,7 @@ export function getMetricDetailsQuery(
|
||||
},
|
||||
]
|
||||
: [],
|
||||
...(limit ? { limit } : {}),
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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';
|
||||
@@ -26,15 +24,17 @@ 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,8 +45,9 @@ function MetricsSearch({
|
||||
expression: currentQueryFilterExpression,
|
||||
},
|
||||
}}
|
||||
onRun={handleOnChange}
|
||||
onRun={handleRunQuery}
|
||||
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: 16px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.metrics-search-options {
|
||||
@@ -51,10 +51,6 @@
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
.lucide-info {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.query-builder-search-container {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -66,8 +62,6 @@
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -15,13 +15,12 @@ import {
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
convertExpressionToFilters,
|
||||
convertFiltersToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
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';
|
||||
@@ -61,10 +60,13 @@ function Summary(): JSX.Element {
|
||||
heatmapView,
|
||||
setHeatmapView,
|
||||
] = useState<MetricsexplorertypesTreemapModeDTO>(
|
||||
MetricsexplorertypesTreemapModeDTO.timeseries,
|
||||
MetricsexplorertypesTreemapModeDTO.samples,
|
||||
);
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
|
||||
|
||||
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
|
||||
currentQuery,
|
||||
]);
|
||||
@@ -89,6 +91,15 @@ 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',
|
||||
@@ -100,11 +111,6 @@ 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),
|
||||
@@ -187,6 +193,7 @@ function Summary(): JSX.Element {
|
||||
},
|
||||
});
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
setAppliedFilterExpression(expression);
|
||||
setCurrentPage(1);
|
||||
if (expression) {
|
||||
logEvent(MetricsExplorerEvents.FilterApplied, {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AggregatorFilter,
|
||||
GroupByFilter,
|
||||
HavingFilter,
|
||||
MetricNameSelector,
|
||||
OperatorsSelect,
|
||||
OrderByFilter,
|
||||
ReduceToFilter,
|
||||
@@ -403,7 +404,7 @@ export const Query = memo(function Query({
|
||||
)}
|
||||
|
||||
<Col flex="auto">
|
||||
<AggregatorFilter
|
||||
<MetricNameSelector
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.metric-name-selector {
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--bg-slate-200);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
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,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ATTRIBUTE_TYPES } 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';
|
||||
|
||||
// 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.
|
||||
|
||||
function getAttributeType(
|
||||
metric: MetricsexplorertypesListMetricDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
if (metric.type === MetrictypesTypeDTO.sum && !metric.isMonotonic) {
|
||||
return ATTRIBUTE_TYPES.GAUGE;
|
||||
}
|
||||
|
||||
const mapping: 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,
|
||||
};
|
||||
|
||||
return mapping[metric.type] || '';
|
||||
}
|
||||
|
||||
function toAutocompleteData(
|
||||
metricName: string,
|
||||
type: string,
|
||||
): BaseAutocompleteData {
|
||||
return { key: metricName, type, dataType: DataTypes.Float64 };
|
||||
}
|
||||
|
||||
export type MetricNameSelectorProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
|
||||
disabled?: boolean;
|
||||
defaultValue?: string;
|
||||
onSelect?: (value: BaseAutocompleteData) => void;
|
||||
signalSource?: 'meter' | '';
|
||||
};
|
||||
|
||||
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>(
|
||||
defaultValue || currentMetricName,
|
||||
);
|
||||
const [searchText, setSearchText] = useState<string>(currentMetricName);
|
||||
|
||||
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
|
||||
const selectedFromDropdownRef = useRef(false);
|
||||
const prevSignalSourceRef = useRef(signalSource);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(defaultValue || currentMetricName);
|
||||
}, [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(
|
||||
toAutocompleteData(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 toAutocompleteData(found.metricName, getAttributeType(found));
|
||||
}
|
||||
return toAutocompleteData(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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { MetricNameSelectorProps } from './MetricNameSelector';
|
||||
export { MetricNameSelector } from './MetricNameSelector';
|
||||
@@ -2,6 +2,7 @@ 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,7 +257,9 @@ function TimeSeriesView({
|
||||
chartData[0]?.length === 0 &&
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
dataSource === DataSource.METRICS && <EmptyMetricsSearch />}
|
||||
dataSource === DataSource.METRICS && (
|
||||
<EmptyMetricsSearch hasQueryResult={data !== undefined} />
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
|
||||
@@ -248,19 +248,12 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
);
|
||||
|
||||
const handleChangeAggregatorAttribute = useCallback(
|
||||
(
|
||||
value: BaseAutocompleteData,
|
||||
isEditMode?: boolean,
|
||||
attributeKeys?: BaseAutocompleteData[],
|
||||
): void => {
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean): 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
|
||||
@@ -311,9 +304,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
// Get current metric info
|
||||
const currentMetricType = newQuery.aggregateAttribute?.type || '';
|
||||
|
||||
const prevMetricType = previousMetricInfo?.type
|
||||
? previousMetricInfo.type
|
||||
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
|
||||
const prevMetricType = previousMetricInfo?.type || '';
|
||||
|
||||
// Check if metric type has changed by comparing with tracked previous values
|
||||
const metricTypeChanged =
|
||||
@@ -374,7 +365,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 - 'rate', space - 'sum', op - 'count'
|
||||
// If not typed - time - 'avg', space - 'sum'
|
||||
if (isEmpty(newQuery.aggregateAttribute?.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
|
||||
newQuery.aggregations = [
|
||||
@@ -388,7 +379,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
@@ -408,6 +399,29 @@ 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export const stepIntervalUnchanged = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'count',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -177,7 +177,7 @@ export const replaceVariables = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'count',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -267,7 +267,7 @@ export const defaultOutput = {
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
temporality: '',
|
||||
timeAggregation: 'count',
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
filter: { expression: '' },
|
||||
@@ -392,7 +392,7 @@ export const outputWithFunctions = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'count',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -429,7 +429,7 @@ export const outputWithFunctions = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'count',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
|
||||
@@ -72,7 +72,6 @@ export type UseQueryOperations = (
|
||||
handleChangeAggregatorAttribute: (
|
||||
value: BaseAutocompleteData,
|
||||
isEditMode?: boolean,
|
||||
attributeKeys?: BaseAutocompleteData[],
|
||||
) => void;
|
||||
handleChangeDataSource: (newSource: DataSource) => void;
|
||||
handleDeleteQuery: () => void;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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,6 +14,7 @@ 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"
|
||||
@@ -56,11 +57,81 @@ 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")
|
||||
|
||||
|
||||
@@ -1631,8 +1631,10 @@ func (t *telemetryMetaStore) FetchTemporalityAndTypeMulti(ctx context.Context, q
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// TODO: return error after table migration are run
|
||||
meterMetricsTemporality, meterMetricsTypes, _ := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
|
||||
meterMetricsTemporality, meterMetricsTypes, err := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// For metrics not found in the database, set to Unknown
|
||||
for _, metricName := range metricNames {
|
||||
@@ -1728,6 +1730,7 @@ 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)
|
||||
|
||||
@@ -1,578 +0,0 @@
|
||||
// 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"`
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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,6 +301,7 @@ 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"`
|
||||
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty"`
|
||||
// table hints to use for the query
|
||||
TableHints *metrictypes.MetricTableHints `json:"-"`
|
||||
// value filter to apply to the query
|
||||
|
||||
Reference in New Issue
Block a user