Compare commits

..

9 Commits

Author SHA1 Message Date
Ashwin Bhatkal
4f0e245e3d chore(frontend): dynamic vars load with empty textbox variables (#10480)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix(frontend): dynamic vars load with empty textbox variables
2026-03-03 16:11:57 +05:30
Nikhil Mantri
a5549f6d5b chore: add status info icon and include additional metrics for status (#10231) 2026-03-03 10:13:52 +00:00
Srikanth Chekuri
1d967fadac chore(metrics-explorer): handle errors properly (#10474) 2026-03-03 09:27:09 +00:00
Abhi kumar
5af6ed6148 chore: updated config builder types (#10477)
* chore: made baseconfigbuilder generic to be used across different charts

* chore: updated baseconfigbuilder test

* chore: updated timezone types

* chore: fixed tsc + test

* chore: fixed tsc + test

* chore: fixed tsc + test

* chore: updated config builder types

* chore: updated comment
2026-03-03 14:17:57 +05:30
Abhi kumar
d094e9cb45 chore: made baseconfigbuilder generic to be used across different charts (#10451)
* chore: made baseconfigbuilder generic to be used across different charts

* chore: updated baseconfigbuilder test

* chore: updated timezone types

* chore: fixed tsc + test

* chore: fixed tsc + test

* chore: fixed tsc + test
2026-03-03 14:05:54 +05:30
Srikanth Chekuri
dbcd1a598e chore: add basic integration tests for meter (#10463)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-02 23:47:54 +05:30
Srikanth Chekuri
00ce5a91ea fix(metrics-explorer): address several bugs in explorer tab (#10458) 2026-03-02 22:11:20 +05:30
SagarRajput-7
4b84b715b4 feat: removed the sso auth announcement banner (#10471)
* feat: removed the sso auth announcement banner

* feat: updated icon to use signozhq icons
2026-03-02 19:00:03 +05:30
Srikanth Chekuri
b3c08ec417 chore: address gaps in summary tab (#10462)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-02 11:52:50 +00:00
151 changed files with 4411 additions and 2964 deletions

View File

@@ -18,7 +18,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -80,8 +79,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, userGetter user.Getter) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, userGetter)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -9,12 +9,12 @@ import (
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
eequerier "github.com/SigNoz/signoz/ee/querier"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
@@ -29,7 +29,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
@@ -120,8 +119,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), licensing, dashboardModule)
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, userGetter user.Getter) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, userGetter)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

View File

@@ -6167,6 +6167,10 @@ paths:
name: searchText
schema:
type: string
- in: query
name: source
schema:
type: string
responses:
"200":
content:

View File

@@ -2,45 +2,39 @@ module base
type organisation
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
type user
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
type serviceaccount
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type anonymous
type role
relations
define assignee: [user, serviceaccount, anonymous]
define assignee: [user, anonymous]
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type metaresources
relations
define create: [user, serviceaccount, role#assignee]
define list: [user, serviceaccount, role#assignee]
define create: [user, role#assignee]
define list: [user, role#assignee]
type metaresource
relations
define read: [user, serviceaccount, anonymous, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define read: [user, anonymous, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
define block: [user, serviceaccount, role#assignee]
define block: [user, role#assignee]
type telemetryresource
relations
define read: [user, serviceaccount, role#assignee]
define read: [user, role#assignee]

View File

@@ -31,22 +31,9 @@ func (server *Server) Stop(ctx context.Context) error {
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser.StringValue():
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount.StringValue():
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
subject = serviceAccount
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)

View File

@@ -11,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
@@ -30,9 +29,9 @@ type module struct {
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, userGetter user.Getter) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, userGetter)
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -209,8 +208,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedByUserID valuer.UUID, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedByUserID, lock)
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {

View File

@@ -216,7 +216,8 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
}),
otelmux.WithPublicEndpoint(),
))
r.Use(middleware.NewAuthN([]string{"Authorization", "Sec-WebSocket-Protocol"}, []string{"SIGNOZ_API_KEY"}, s.signoz.Sharder, s.signoz.Tokenizer, s.signoz.ServiceAccountTokenizer, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAuthN([]string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Tokenizer, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,

View File

@@ -2,7 +2,7 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
// Handles errors from generated API hooks (which use RenderErrorResponseDTO)
// @deprecated Use convertToApiError instead
export function ErrorResponseHandlerForGeneratedAPIs(
error: AxiosError<RenderErrorResponseDTO>,
): never {
@@ -46,3 +46,34 @@ export function ErrorResponseHandlerForGeneratedAPIs(
},
});
}
// convertToApiError converts an AxiosError from generated API
// hooks into an APIError.
export function convertToApiError(
error: AxiosError<RenderErrorResponseDTO> | null,
): APIError | undefined {
if (!error) {
return undefined;
}
const response = error.response;
const errorData = response?.data?.error;
return new APIError({
httpStatusCode: response?.status || error.status || 500,
error: {
code:
errorData?.code ||
String(response?.status || error.code || 'unknown_error'),
message:
errorData?.message ||
response?.statusText ||
error.message ||
'Something went wrong',
url: errorData?.url ?? '',
errors: (errorData?.errors ?? []).map((e) => ({
message: e.message ?? '',
})),
},
});
}

View File

@@ -3451,6 +3451,11 @@ export type ListMetricsParams = {
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
source?: string;
};
export type ListMetrics200 = {

View File

@@ -1,54 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { TreemapViewType } from 'container/MetricsExplorer/Summary/types';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsTreeMapPayload {
filters: TagFilter;
limit?: number;
treemap?: TreemapViewType;
}
export interface MetricsTreeMapResponse {
status: string;
data: {
[TreemapViewType.TIMESERIES]: TimeseriesData[];
[TreemapViewType.SAMPLES]: SamplesData[];
};
}
export interface TimeseriesData {
percentage: number;
total_value: number;
metric_name: string;
}
export interface SamplesData {
percentage: number;
metric_name: string;
}
export const getMetricsTreeMap = async (
props: MetricsTreeMapPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<MetricsTreeMapResponse> | ErrorResponse> => {
try {
const response = await axios.post('/metrics/treemap', props, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
params: props,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Temporality } from './getMetricDetails';
import { MetricType } from './getMetricsList';
export interface UpdateMetricMetadataProps {
description: string;
metricType: MetricType;
temporality?: Temporality;
isMonotonic?: boolean;
unit?: string;
}
export interface UpdateMetricMetadataResponse {
success: boolean;
message: string;
}
const updateMetricMetadata = async (
metricName: string,
props: UpdateMetricMetadataProps,
): Promise<SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse> => {
const response = await axios.post(`/metrics/${metricName}/metadata`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateMetricMetadata;

View File

@@ -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(

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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,
);

View File

@@ -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');

View File

@@ -29,7 +29,6 @@ export enum LOCALSTORAGE {
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
BANNER_DISMISSED = 'BANNER_DISMISSED',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',

View File

@@ -1,4 +1,5 @@
// ** Helpers
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -177,7 +178,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MetricAggregateOperator.COUNT,
timeAggregation: MetricAggregateOperator.AVG,
spaceAggregation: MetricAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},
@@ -225,7 +226,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MeterAggregateOperator.COUNT,
timeAggregation: MeterAggregateOperator.AVG,
spaceAggregation: MeterAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},
@@ -371,6 +372,31 @@ export enum ATTRIBUTE_TYPES {
EXPONENTIAL_HISTOGRAM = 'ExponentialHistogram',
}
const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
MetrictypesTypeDTO,
ATTRIBUTE_TYPES
> = {
[MetrictypesTypeDTO.sum]: ATTRIBUTE_TYPES.SUM,
[MetrictypesTypeDTO.gauge]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.histogram]: ATTRIBUTE_TYPES.HISTOGRAM,
[MetrictypesTypeDTO.summary]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.exponentialhistogram]:
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
};
export function toAttributeType(
metricType: MetrictypesTypeDTO | undefined,
isMonotonic?: boolean,
): ATTRIBUTE_TYPES | '' {
if (!metricType) {
return '';
}
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
}
export type IQueryBuilderState = 'search';
export const QUERY_BUILDER_SEARCH_VALUES = {

View File

@@ -49,7 +49,6 @@ export const REACT_QUERY_KEY = {
// Metrics Explorer Query Keys
GET_METRICS_LIST: 'GET_METRICS_LIST',
GET_METRICS_TREE_MAP: 'GET_METRICS_TREE_MAP',
GET_METRICS_LIST_FILTER_KEYS: 'GET_METRICS_LIST_FILTER_KEYS',
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',

View File

@@ -441,7 +441,7 @@ describe('Footer utils', () => {
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'count',
timeAggregation: 'avg',
},
],
disabled: false,

View File

@@ -1,3 +1,4 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -8,7 +9,7 @@ interface BaseChartProps {
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone: string;
timezone?: Timezone;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;

View File

@@ -129,12 +129,12 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
timezone={timezone}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -5,12 +5,7 @@ import { getInitialStackedBands } from 'container/DashboardContainer/visualizati
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
LineInterpolation,
LineStyle,
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
@@ -63,7 +58,12 @@ export function prepareBarPanelConfig({
const minStepInterval = Math.min(...Object.values(stepIntervals));
const builder = buildBaseConfig({
widget,
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
isLogScale: widget.isLogScale,
isDarkMode,
onClick,
onDragSelect,
@@ -98,14 +98,8 @@ export function prepareBarPanelConfig({
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.BAR,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
stepInterval: currentStepInterval,
});

View File

@@ -100,7 +100,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone.value}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -154,7 +154,12 @@ export function prepareHistogramPanelConfig({
isDarkMode: boolean;
}): UPlotConfigBuilder {
const builder = buildBaseConfig({
widget,
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
isLogScale: widget.isLogScale,
isDarkMode,
apiResponse,
panelMode,
@@ -191,10 +196,8 @@ export function prepareHistogramPanelConfig({
builder.addSeries({
label: '',
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.HISTOGRAM,
drawStyle: DrawStyle.Histogram,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
barWidthFactor: 1,
pointSize: 5,
lineColor: '#3f5ecc',
@@ -216,10 +219,8 @@ export function prepareHistogramPanelConfig({
builder.addSeries({
label: label,
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.HISTOGRAM,
drawStyle: DrawStyle.Histogram,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
barWidthFactor: 1,
pointSize: 5,
isDarkMode,

View File

@@ -118,7 +118,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
}}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone.value}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -82,7 +82,12 @@ export const prepareUPlotConfig = ({
const minStepInterval = Math.min(...Object.values(stepIntervals));
const builder = buildBaseConfig({
widget,
id: widget.id,
thresholds: widget.thresholds,
yAxisUnit: widget.yAxisUnit,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
isLogScale: widget.isLogScale,
isDarkMode,
onClick,
onDragSelect,
@@ -120,7 +125,6 @@ export const prepareUPlotConfig = ({
: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
});

View File

@@ -1,11 +1,11 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { STEP_INTERVAL_MULTIPLIER } from 'lib/uPlotV2/constants';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { PanelMode } from '../../types';
import { buildBaseConfig } from '../baseConfigBuilder';
import { BaseConfigBuilderProps, buildBaseConfig } from '../baseConfigBuilder';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
@@ -27,16 +27,25 @@ jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
}));
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
softMin: undefined,
softMax: undefined,
thresholds: [],
...overrides,
} as Widgets);
const createBaseConfigBuilderProps = (
overrides: Partial<
Pick<
BaseConfigBuilderProps,
'id' | 'yAxisUnit' | 'isLogScale' | 'softMin' | 'softMax' | 'thresholds'
>
> = {},
): Pick<
BaseConfigBuilderProps,
'id' | 'yAxisUnit' | 'isLogScale' | 'softMin' | 'softMax' | 'thresholds'
> => ({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
softMin: undefined,
softMax: undefined,
thresholds: [],
...overrides,
});
const createApiResponse = (
overrides: Partial<MetricRangePayloadProps> = {},
@@ -47,7 +56,7 @@ const createApiResponse = (
} as MetricRangePayloadProps);
const baseProps = {
widget: createWidget(),
...createBaseConfigBuilderProps(),
apiResponse: createApiResponse(),
isDarkMode: true,
panelMode: PanelMode.DASHBOARD_VIEW,
@@ -63,14 +72,14 @@ describe('buildBaseConfig', () => {
expect(typeof builder.getLegendItems).toBe('function');
});
it('configures builder with widgetId and DASHBOARD_VIEW preferences', () => {
it('configures builder with id and DASHBOARD_VIEW preferences', () => {
const builder = buildBaseConfig({
...baseProps,
panelMode: PanelMode.DASHBOARD_VIEW,
widget: createWidget({ id: 'my-widget' }),
...createBaseConfigBuilderProps({ id: 'my-widget' }),
});
expect(builder.getWidgetId()).toBe('my-widget');
expect(builder.getId()).toBe('my-widget');
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
});
@@ -127,7 +136,7 @@ describe('buildBaseConfig', () => {
it('configures log scale on y axis when widget.isLogScale is true', () => {
const builder = buildBaseConfig({
...baseProps,
widget: createWidget({ isLogScale: true }),
...createBaseConfigBuilderProps({ isLogScale: true }),
});
const config = builder.getConfig();
@@ -171,7 +180,7 @@ describe('buildBaseConfig', () => {
it('adds thresholds from widget', () => {
const builder = buildBaseConfig({
...baseProps,
widget: createWidget({
...createBaseConfigBuilderProps({
thresholds: [
{
thresholdValue: 80,
@@ -179,7 +188,7 @@ describe('buildBaseConfig', () => {
thresholdUnit: 'ms',
thresholdLabel: 'High',
},
] as Widgets['thresholds'],
] as ThresholdProps[],
}),
});

View File

@@ -1,5 +1,6 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -9,28 +10,32 @@ import {
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { PanelMode } from '../types';
export interface BaseConfigBuilderProps {
widget: Widgets;
id: string;
thresholds?: ThresholdProps[];
apiResponse: MetricRangePayloadProps;
isDarkMode: boolean;
onClick?: OnClickPluginOpts['onClick'];
onDragSelect?: (startTime: number, endTime: number) => void;
timezone?: Timezone;
panelMode: PanelMode;
panelMode?: PanelMode;
panelType: PANEL_TYPES;
minTimeScale?: number;
maxTimeScale?: number;
stepInterval?: number;
isLogScale?: boolean;
yAxisUnit?: string;
softMin?: number;
softMax?: number;
}
export function buildBaseConfig({
widget,
id,
isDarkMode,
onClick,
onDragSelect,
@@ -38,9 +43,14 @@ export function buildBaseConfig({
timezone,
panelMode,
panelType,
thresholds,
minTimeScale,
maxTimeScale,
stepInterval,
isLogScale,
yAxisUnit,
softMin,
softMax,
}: BaseConfigBuilderProps): UPlotConfigBuilder {
const tzDate = timezone
? (timestamp: number): Date =>
@@ -48,28 +58,27 @@ export function buildBaseConfig({
: undefined;
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
widgetId: widget.id,
tzDate,
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: [
PanelMode.DASHBOARD_VIEW,
PanelMode.STANDALONE_VIEW,
].includes(panelMode)
? SelectionPreferencesSource.LOCAL_STORAGE
selectionPreferencesSource: panelMode
? [PanelMode.DASHBOARD_VIEW, PanelMode.STANDALONE_VIEW].includes(panelMode)
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY
: SelectionPreferencesSource.IN_MEMORY,
stepInterval,
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: (widget.thresholds || []).map((threshold) => ({
thresholds: (thresholds || []).map((threshold) => ({
thresholdValue: threshold.thresholdValue ?? 0,
thresholdColor: threshold.thresholdColor,
thresholdUnit: threshold.thresholdUnit,
thresholdLabel: threshold.thresholdLabel,
})),
yAxisUnit: widget.yAxisUnit,
yAxisUnit: yAxisUnit,
};
builder.addThresholds(thresholdOptions);
@@ -79,8 +88,8 @@ export function buildBaseConfig({
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: widget.isLogScale ? 10 : undefined,
distribution: widget.isLogScale
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
@@ -91,11 +100,11 @@ export function buildBaseConfig({
time: false,
min: undefined,
max: undefined,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
softMin: softMin,
softMax: softMax,
thresholds: thresholdOptions,
logBase: widget.isLogScale ? 10 : undefined,
distribution: widget.isLogScale
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
@@ -114,7 +123,7 @@ export function buildBaseConfig({
show: true,
side: 2,
isDarkMode,
isLogScale: widget.isLogScale,
isLogScale,
panelType,
});
@@ -123,8 +132,8 @@ export function buildBaseConfig({
show: true,
side: 3,
isDarkMode,
isLogScale: widget.isLogScale,
yAxisUnit: widget.yAxisUnit,
isLogScale,
yAxisUnit,
panelType,
});

View File

@@ -15,7 +15,7 @@ export const getRandomColor = (): string => {
};
export const DATASOURCE_VS_ROUTES: Record<DataSource, string> = {
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER,
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER_EXPLORER,
[DataSource.TRACES]: ROUTES.TRACES_EXPLORER,
[DataSource.LOGS]: ROUTES.LOGS_EXPLORER,
};

View File

@@ -25,51 +25,6 @@
background: var(--bg-slate-500);
}
.home-container-banner {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 8px 12px;
width: 100%;
background-color: var(--bg-robin-500);
.home-container-banner-close {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--bg-vanilla-100);
position: absolute;
right: 12px;
}
.home-container-banner-content {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
.home-container-banner-link {
color: var(--bg-vanilla-100);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
text-decoration: underline;
}
}
}
.home-header-left {
display: flex;
align-items: center;

View File

@@ -1,13 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -15,10 +15,8 @@ import ROUTES from 'constants/routes';
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import cloneDeep from 'lodash-es/cloneDeep';
import { CompassIcon, DotIcon, HomeIcon, Plus, Wrench, X } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import * as motion from 'motion/react-client';
import Card from 'periscope/components/Card/Card';
@@ -51,8 +49,6 @@ export default function Home(): JSX.Element {
const [updatingUserPreferences, setUpdatingUserPreferences] = useState(false);
const [loadingUserPreferences, setLoadingUserPreferences] = useState(true);
const { isCommunityUser, isCommunityEnterpriseUser } = useGetTenantLicense();
const [checklistItems, setChecklistItems] = useState<ChecklistItem[]>(
defaultChecklistItemsState,
);
@@ -61,13 +57,6 @@ export default function Home(): JSX.Element {
false,
);
const [isBannerDismissed, setIsBannerDismissed] = useState(false);
useEffect(() => {
const bannerDismissed = localStorage.getItem(LOCALSTORAGE.BANNER_DISMISSED);
setIsBannerDismissed(bannerDismissed === 'true');
}, []);
useEffect(() => {
const now = new Date();
const startTime = new Date(now.getTime() - homeInterval);
@@ -298,44 +287,13 @@ export default function Home(): JSX.Element {
logEvent('Homepage: Visited', {});
}, []);
const hideBanner = (): void => {
localStorage.setItem(LOCALSTORAGE.BANNER_DISMISSED, 'true');
setIsBannerDismissed(true);
};
const showBanner = useMemo(
() => !isBannerDismissed && (isCommunityUser || isCommunityEnterpriseUser),
[isBannerDismissed, isCommunityUser, isCommunityEnterpriseUser],
);
return (
<div className="home-container">
<div className="sticky-header">
{showBanner && (
<div className="home-container-banner">
<div className="home-container-banner-content">
Big News: SigNoz Community Edition now available with SSO (Google OAuth)
and API keys -
<a
href="https://signoz.io/blog/open-source-signoz-now-available-with-sso-and-api-keys/"
target="_blank"
rel="noreferrer"
className="home-container-banner-link"
>
<i>read more</i>
</a>
</div>
<div className="home-container-banner-close">
<X size={16} onClick={hideBanner} />
</div>
</div>
)}
<Header
leftComponent={
<div className="home-header-left">
<HomeIcon size={14} /> Home
<House size={14} /> Home
</div>
}
rightComponent={
@@ -400,7 +358,7 @@ export default function Home(): JSX.Element {
<div className="active-ingestion-card-content-container">
<div className="active-ingestion-card-content">
<div className="active-ingestion-card-content-icon">
<DotIcon size={16} color={Color.BG_FOREST_500} />
<Dot size={16} color={Color.BG_FOREST_500} />
</div>
<div className="active-ingestion-card-content-description">
@@ -427,7 +385,7 @@ export default function Home(): JSX.Element {
}
}}
>
<CompassIcon size={12} />
<Compass size={12} />
Explore Logs
</div>
</div>
@@ -441,7 +399,7 @@ export default function Home(): JSX.Element {
<div className="active-ingestion-card-content-container">
<div className="active-ingestion-card-content">
<div className="active-ingestion-card-content-icon">
<DotIcon size={16} color={Color.BG_FOREST_500} />
<Dot size={16} color={Color.BG_FOREST_500} />
</div>
<div className="active-ingestion-card-content-description">
@@ -468,7 +426,7 @@ export default function Home(): JSX.Element {
}
}}
>
<CompassIcon size={12} />
<Compass size={12} />
Explore Traces
</div>
</div>
@@ -482,7 +440,7 @@ export default function Home(): JSX.Element {
<div className="active-ingestion-card-content-container">
<div className="active-ingestion-card-content">
<div className="active-ingestion-card-content-icon">
<DotIcon size={16} color={Color.BG_FOREST_500} />
<Dot size={16} color={Color.BG_FOREST_500} />
</div>
<div className="active-ingestion-card-content-description">
@@ -509,7 +467,7 @@ export default function Home(): JSX.Element {
}
}}
>
<CompassIcon size={12} />
<Compass size={12} />
Explore Metrics
</div>
</div>

View File

@@ -190,6 +190,11 @@
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.status-header {
display: flex;
align-items: center;
gap: 4px;
}
.memory-usage-header {
display: flex;
align-items: center;

View File

@@ -146,7 +146,14 @@ export const getHostsListColumns = (): ColumnType<HostRowData>[] => [
),
},
{
title: 'Status',
title: (
<div className="status-header">
Status
<Tooltip title="Sent system metrics in last 10 mins">
<InfoCircleOutlined />
</Tooltip>
</div>
),
dataIndex: 'active',
key: 'active',
width: 100,

View File

@@ -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>
);

View File

@@ -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>
}
/>

View File

@@ -12,14 +12,21 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
ICurrentQueryData,
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { explorerViewToPanelType } from 'utils/explorerUtils';
import { v4 as uuid } from 'uuid';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
@@ -42,15 +49,20 @@ function Explorer(): JSX.Element {
stagedQuery,
updateAllQueriesOperators,
currentQuery,
handleSetConfig,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const metricNames = useMemo(() => {
const currentMetricNames: string[] = [];
stagedQuery?.builder.queryData.forEach((query) => {
if (query.aggregateAttribute?.key) {
currentMetricNames.push(query.aggregateAttribute?.key);
const metricName =
query.aggregateAttribute?.key ||
(query.aggregations?.[0] as MetricAggregation | undefined)?.metricName;
if (metricName) {
currentMetricNames.push(metricName);
}
});
return currentMetricNames;
@@ -69,7 +81,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],
);
@@ -176,6 +188,16 @@ function Explorer(): JSX.Element {
useShareBuilderUrl({ defaultValue: defaultQuery });
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
const nextPanelType =
explorerViewToPanelType[view] || PANEL_TYPES.TIME_SERIES;
handleSetConfig(nextPanelType, DataSource.METRICS);
handleExplorerTabChange(nextPanelType, querySearchParameters);
},
[handleSetConfig, handleExplorerTabChange],
);
const handleExport = useCallback(
(
dashboard: Dashboard | null,
@@ -348,6 +370,7 @@ function Explorer(): JSX.Element {
onExport={handleExport}
isOneChartPerQuery={showOneChartPerQuery}
splitedQueries={splitedQueries}
handleChangeSelectedView={handleChangeSelectedView}
/>
{isMetricDetailsOpen && selectedMetricName && (
<MetricDetails

View File

@@ -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>
</>
);

View File

@@ -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();
});
});

View File

@@ -8,10 +8,11 @@ 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';
import * as useHandleExplorerTabChangeHooks from 'hooks/useHandleExplorerTabChange';
import * as appContextHooks from 'providers/App/App';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import * as timezoneHooks from 'providers/Timezone';
@@ -29,6 +30,8 @@ const queryClient = new QueryClient();
const mockUpdateAllQueriesOperators = jest
.fn()
.mockReturnValue(initialQueriesMap[DataSource.METRICS]);
const mockHandleSetConfig = jest.fn();
const mockHandleExplorerTabChange = jest.fn();
const mockUseQueryBuilderData = {
handleRunQuery: jest.fn(),
stagedQuery: initialQueriesMap[DataSource.METRICS],
@@ -40,7 +43,7 @@ const mockUseQueryBuilderData = {
handleSetQueryData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
handleSetConfig: mockHandleSetConfig,
removeQueryBuilderEntityByIndex: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
isDefaultQuery: jest.fn(),
@@ -135,6 +138,11 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
jest
.spyOn(useHandleExplorerTabChangeHooks, 'useHandleExplorerTabChange')
.mockReturnValue({
handleExplorerTabChange: mockHandleExplorerTabChange,
});
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
@@ -157,26 +165,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 +229,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 +341,158 @@ 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();
});
describe('loading saved views with v5 query format', () => {
const EMPTY_STATE_TEXT = 'Select a metric and run a query to see the results';
it('should show empty state when no metric is selected', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({}),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [],
});
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
renderExplorer();
expect(screen.getByText(EMPTY_STATE_TEXT)).toBeInTheDocument();
});
it('should not show empty state when saved view has v5 aggregations format', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({}),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_METADATA],
});
// saved view loaded back from v5 format
// aggregateAttribute.key is empty (lost in v3/v4 -> v5 -> v3/v4 round trip)
// but aggregations[0].metricName has metric name
// TODO(srikanthccv): remove this mess
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: '',
},
aggregations: [
{
metricName: 'http_requests_total',
temporality: 'cumulative',
timeAggregation: 'rate',
spaceAggregation: 'sum',
},
],
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData],
},
},
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
renderExplorer();
expect(screen.queryByText(EMPTY_STATE_TEXT)).not.toBeInTheDocument();
});
it('should not show empty state when query uses v3 aggregateAttribute format', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({}),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_METADATA],
});
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'system_cpu_usage',
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData],
},
},
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
renderExplorer();
expect(screen.queryByText(EMPTY_STATE_TEXT)).not.toBeInTheDocument();
});
});
});

View File

@@ -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),
}),
);
});
});

View File

@@ -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();
});
});

View File

@@ -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}

View File

@@ -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');
});

View File

@@ -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,50 @@ 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,
isMonotonic,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
const [searchString, setSearchString] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
const [keyPopoverOpen, setKeyPopoverOpen] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [, copyToClipboard] = useCopyToClipboard();
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const {
data: attributesData,
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
refetch: refetchAttributes,
} = useGetMetricAttributes({
metricName,
});
} = 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 +66,15 @@ 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,
isMonotonic,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -179,6 +84,7 @@ function AllAttributes({
id: metricName,
},
ROUTES.METRICS_EXPLORER_EXPLORER,
true,
);
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
[MetricsExplorerEventKeys.MetricName]: metricName,
@@ -187,15 +93,19 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
});
},
[metricName, metricType, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
(key: string, value: string) => {
const compositeQuery = getMetricDetailsQuery(metricName, metricType, {
key,
value,
});
const compositeQuery = getMetricDetailsQuery(
metricName,
metricType,
{ key, value },
undefined,
undefined,
isMonotonic,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
{
@@ -204,6 +114,7 @@ function AllAttributes({
id: metricName,
},
ROUTES.METRICS_EXPLORER_EXPLORER,
true,
);
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
[MetricsExplorerEventKeys.MetricName]: metricName,
@@ -213,7 +124,29 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeValue]: value,
});
},
[metricName, metricType, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(
(menuKey: string, attributeKey: string, valueCount?: number): void => {
switch (menuKey) {
case 'open-in-explorer':
goToMetricsExplorerwithAppliedSpaceAggregation(attributeKey, valueCount);
break;
case 'copy-key':
copyToClipboard(attributeKey);
setCopiedKey(attributeKey);
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => {
setCopiedKey(null);
}, COPY_FEEDBACK_DURATION_MS);
break;
default:
break;
}
setKeyPopoverOpen(null);
},
[goToMetricsExplorerwithAppliedSpaceAggregation, copyToClipboard],
);
const filteredAttributes = useMemo(
@@ -254,21 +187,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 +260,9 @@ function AllAttributes({
],
[
goToMetricsExploreWithAppliedAttribute,
goToMetricsExplorerwithAppliedSpaceAggregation,
handleKeyMenuItemClick,
keyPopoverOpen,
copiedKey,
],
);
@@ -300,7 +271,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 +305,9 @@ function AllAttributes({
className="metrics-accordion-content all-attributes-content"
scroll={{ y: 600 }}
locale={{
emptyText: (
emptyText: isLoadingAttributes ? (
' '
) : (
<AllAttributesEmptyText
isErrorAttributes={isErrorAttributes}
refetchAttributes={refetchAttributes}
@@ -350,14 +328,6 @@ function AllAttributes({
],
);
if (isLoadingAttributes) {
return (
<div className="all-attributes-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<Collapse
bordered

View File

@@ -0,0 +1,212 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Input, Menu, Popover, Tooltip, Typography } from 'antd';
import { Check, Copy, Search, SquareArrowOutUpRight } from 'lucide-react';
import MetricDetailsErrorState from './MetricDetailsErrorState';
import { AllAttributesEmptyTextProps, AllAttributesValueProps } from './types';
const INITIAL_VISIBLE_COUNT = 5;
const COPY_FEEDBACK_DURATION_MS = 1500;
export function AllAttributesEmptyText({
isErrorAttributes,
refetchAttributes,
}: AllAttributesEmptyTextProps): JSX.Element {
if (isErrorAttributes) {
return (
<div className="all-attributes-error-state">
<MetricDetailsErrorState
refetch={refetchAttributes}
errorMessage="Something went wrong while fetching attributes"
/>
</div>
);
}
return <Typography.Text>No attributes found</Typography.Text>;
}
export function AllAttributesValue({
filterKey,
filterValue,
goToMetricsExploreWithAppliedAttribute,
}: AllAttributesValueProps): JSX.Element {
const [attributePopoverKey, setAttributePopoverKey] = useState<string | null>(
null,
);
const [allValuesOpen, setAllValuesOpen] = useState(false);
const [allValuesSearch, setAllValuesSearch] = useState('');
const [copiedValue, setCopiedValue] = useState<string | null>(null);
const [, copyToClipboard] = useCopyToClipboard();
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const handleCopyWithFeedback = useCallback(
(value: string): void => {
copyToClipboard(value);
setCopiedValue(value);
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => {
setCopiedValue(null);
}, COPY_FEEDBACK_DURATION_MS);
},
[copyToClipboard],
);
const handleMenuItemClick = useCallback(
(key: string, attribute: string): void => {
switch (key) {
case 'open-in-explorer':
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
break;
case 'copy-value':
handleCopyWithFeedback(attribute);
break;
default:
break;
}
setAttributePopoverKey(null);
},
[goToMetricsExploreWithAppliedAttribute, filterKey, handleCopyWithFeedback],
);
const attributePopoverContent = useCallback(
(attribute: string) => (
<Menu
items={[
{
icon: <SquareArrowOutUpRight size={14} />,
label: 'Open in Metric Explorer',
key: 'open-in-explorer',
},
{
icon: <Copy size={14} />,
label: 'Copy Value',
key: 'copy-value',
},
]}
onClick={(info): void => {
handleMenuItemClick(info.key, attribute);
}}
/>
),
[handleMenuItemClick],
);
const filteredAllValues = useMemo(
() =>
allValuesSearch
? filterValue.filter((v) =>
v.toLowerCase().includes(allValuesSearch.toLowerCase()),
)
: filterValue,
[filterValue, allValuesSearch],
);
const allValuesPopoverContent = (
<div className="all-values-popover">
<Input
placeholder="Search values"
size="small"
prefix={<Search size={12} />}
value={allValuesSearch}
onChange={(e): void => setAllValuesSearch(e.target.value)}
allowClear
/>
<div className="all-values-list">
{allValuesOpen &&
filteredAllValues.map((attribute) => {
const isCopied = copiedValue === attribute;
return (
<div key={attribute} className="all-values-item">
<Typography.Text ellipsis className="all-values-item-text">
{attribute}
</Typography.Text>
<div className="all-values-item-actions">
<Tooltip title={isCopied ? 'Copied!' : 'Copy value'}>
<Button
type="text"
size="small"
className={isCopied ? 'copy-success' : ''}
icon={isCopied ? <Check size={12} /> : <Copy size={12} />}
onClick={(): void => {
handleCopyWithFeedback(attribute);
}}
/>
</Tooltip>
<Tooltip title="Open in Metric Explorer">
<Button
type="text"
size="small"
icon={<SquareArrowOutUpRight size={12} />}
onClick={(): void => {
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
setAllValuesOpen(false);
}}
/>
</Tooltip>
</div>
</div>
);
})}
{allValuesOpen && filteredAllValues.length === 0 && (
<Typography.Text type="secondary" className="all-values-empty">
No values found
</Typography.Text>
)}
</div>
</div>
);
return (
<div className="all-attributes-value">
{filterValue.slice(0, INITIAL_VISIBLE_COUNT).map((attribute) => {
const isCopied = copiedValue === attribute;
return (
<div key={attribute} className="all-attributes-value-item">
<Popover
content={attributePopoverContent(attribute)}
trigger="click"
overlayClassName="metric-details-popover attribute-value-popover-overlay"
open={attributePopoverKey === `${filterKey}-${attribute}`}
onOpenChange={(open): void => {
if (!open) {
setAttributePopoverKey(null);
} else {
setAttributePopoverKey(`${filterKey}-${attribute}`);
}
}}
>
<Button type="text">
<Typography.Text>{attribute}</Typography.Text>
</Button>
</Popover>
{isCopied && (
<span className="copy-feedback">
<Check size={12} />
</span>
)}
</div>
);
})}
{filterValue.length > INITIAL_VISIBLE_COUNT && (
<Popover
content={allValuesPopoverContent}
trigger="click"
open={allValuesOpen}
onOpenChange={(open): void => {
setAllValuesOpen(open);
if (!open) {
setAllValuesSearch('');
setCopiedValue(null);
}
}}
overlayClassName="metric-details-popover all-values-popover-overlay"
>
<Button type="text" className="all-values-button">
All values ({filterValue.length})
</Button>
</Popover>
)}
</div>
);
}

View File

@@ -9,9 +9,6 @@ import {
} from 'api/generated/services/metrics';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Bell, Grid } from 'lucide-react';
import { pluralize } from 'utils/pluralize';
@@ -20,9 +17,6 @@ import { DashboardsAndAlertsPopoverProps } from './types';
function DashboardsAndAlertsPopover({
metricName,
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery();
const {
data: alertsData,
isLoading: isLoadingAlerts,
@@ -74,8 +68,10 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={alert.alertId}
onClick={(): void => {
params.set(QueryParams.ruleId, alert.alertId);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
window.open(
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${alert.alertId}`,
'_blank',
);
}}
className="dashboards-popover-content-item"
>
@@ -85,7 +81,7 @@ function DashboardsAndAlertsPopover({
}));
}
return null;
}, [alerts, params]);
}, [alerts]);
const dashboardsPopoverContent = useMemo(() => {
if (dashboards && dashboards.length > 0) {
@@ -95,10 +91,11 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={dashboard.dashboardId}
onClick={(): void => {
safeNavigate(
window.open(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboardId,
}),
'_blank',
);
}}
className="dashboards-popover-content-item"
@@ -109,7 +106,7 @@ function DashboardsAndAlertsPopover({
}));
}
return null;
}, [dashboards, safeNavigate]);
}, [dashboards]);
if (isLoadingAlerts || isLoadingDashboards) {
return (

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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;
}
}
}
@@ -228,6 +245,18 @@
background: var(--bg-slate-300);
border-radius: 1px;
}
.all-attributes-value-item {
display: flex;
align-items: center;
gap: 4px;
.copy-feedback {
display: inline-flex;
align-items: center;
color: var(--bg-forest-500);
animation: fade-in-out 1.5s ease-in-out;
}
}
.ant-btn {
text-align: left;
width: fit-content;
@@ -259,10 +288,8 @@
}
.metric-metadata-key {
cursor: pointer;
padding-left: 10px;
vertical-align: middle;
text-align: center;
.field-renderer-container {
.label {
color: var(--bg-vanilla-400);
@@ -383,9 +410,8 @@
}
.all-attributes-key {
.ant-typography:last-child {
color: var(--bg-vanilla-200);
background-color: var(--bg-robin-300);
.all-attributes-contribution {
color: var(--bg-slate-400);
}
}
}
@@ -448,3 +474,146 @@
height: 100%;
width: 100%;
}
.attribute-key-popover-overlay {
.ant-popover-inner {
padding: 0 !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.ant-menu {
font-size: 12px;
background: transparent;
.ant-menu-item {
height: 32px;
line-height: 32px;
padding: 0 12px;
font-size: 12px;
}
}
}
.all-values-popover-overlay {
.ant-popover-inner {
padding: 0 !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
}
.all-values-popover {
width: 320px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
.all-values-list {
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
&::-webkit-scrollbar {
width: 2px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 1px;
}
}
.all-values-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
gap: 8px;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
.all-values-item-text {
flex: 1;
min-width: 0;
font-family: 'Geist Mono';
font-size: 12px;
}
.all-values-item-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
.copy-success {
color: var(--bg-forest-500);
}
}
}
.all-values-empty {
padding: 8px;
text-align: center;
}
}
.all-values-button {
color: var(--bg-robin-400) !important;
}
.lightMode {
.attribute-key-popover-overlay,
.all-values-popover-overlay {
.ant-popover-inner {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100) !important;
}
}
.all-values-popover {
.all-values-item {
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
}
}
@keyframes fade-in-out {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
85% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -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,
@@ -73,7 +80,14 @@ function MetricDetails({
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
if (metricName) {
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
const compositeQuery = getMetricDetailsQuery(
metricName,
metadata?.type,
undefined,
undefined,
undefined,
metadata?.isMonotonic,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
{
@@ -82,6 +96,7 @@ function MetricDetails({
id: metricName,
},
ROUTES.METRICS_EXPLORER_EXPLORER,
true,
);
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
[MetricsExplorerEventKeys.MetricName]: metricName,
@@ -89,7 +104,12 @@ function MetricDetails({
[MetricsExplorerEventKeys.Modal]: 'metric-details',
});
}
}, [metricName, handleExplorerTabChange, metadata?.type]);
}, [
metricName,
handleExplorerTabChange,
metadata?.type,
metadata?.isMonotonic,
]);
useEffect(() => {
logEvent(MetricsExplorerEvents.ModalOpened, {
@@ -100,6 +120,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 +172,7 @@ function MetricDetails({
</div>
}
placement="right"
onClose={onClose}
onClose={handleDrawerClose}
open={isOpen}
style={{
overscrollBehavior: 'contain',
@@ -157,7 +192,13 @@ function MetricDetails({
isLoadingMetricMetadata={isLoadingMetricMetadata}
refetchMetricMetadata={refetchMetricMetadata}
/>
<AllAttributes metricName={metricName} metricType={metadata?.type} />
<AllAttributes
metricName={metricName}
metricType={metadata?.type}
isMonotonic={metadata?.isMonotonic}
minTime={minTime}
maxTime={maxTime}
/>
</div>
</Drawer>
);

View File

@@ -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();
});
});

View File

@@ -14,21 +14,8 @@ import {
MOCK_METRIC_NAME,
} from './testUtlls';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockSetQuery = jest.fn();
const mockUrlQuery = {
set: mockSetQuery,
toString: jest.fn(),
};
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => mockUrlQuery),
}));
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', { value: mockWindowOpen });
const useGetMetricAlertsMock = jest.spyOn(
metricsExplorerHooks,
@@ -43,6 +30,7 @@ describe('DashboardsAndAlertsPopover', () => {
beforeEach(() => {
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
mockWindowOpen.mockClear();
});
it('renders the popover correctly with multiple dashboards and alerts', () => {
@@ -140,9 +128,10 @@ describe('DashboardsAndAlertsPopover', () => {
// Click on the first dashboard
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
// Should navigate to the dashboard
expect(mockSafeNavigate).toHaveBeenCalledWith(
// Should open dashboard in new tab
expect(mockWindowOpen).toHaveBeenCalledWith(
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
'_blank',
);
});
@@ -158,10 +147,9 @@ describe('DashboardsAndAlertsPopover', () => {
// Click on the first alert rule
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
// Should navigate to the alert rule
expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId,
MOCK_ALERT_1.alertId,
expect(mockWindowOpen).toHaveBeenCalledWith(
`/alerts/overview?${QueryParams.ruleId}=${MOCK_ALERT_1.alertId}`,
'_blank',
);
});

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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(),

View File

@@ -2,6 +2,7 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import {
determineIsMonotonic,
@@ -139,7 +140,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.sum,
ATTRIBUTE_TYPES.SUM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
@@ -156,7 +157,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.gauge,
ATTRIBUTE_TYPES.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
@@ -173,7 +174,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.summary,
ATTRIBUTE_TYPES.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
@@ -190,7 +191,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.histogram,
ATTRIBUTE_TYPES.HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
@@ -207,7 +208,7 @@ describe('MetricDetails utils', () => {
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
MetrictypesTypeDTO.exponentialhistogram,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');

View File

@@ -34,6 +34,9 @@ export interface MetadataProps {
export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
minTime?: number;
maxTime?: number;
}
export interface AllAttributesValueProps {

View File

@@ -5,7 +5,7 @@ import {
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
import { initialQueriesMap } from 'constants/queryBuilder';
import { initialQueriesMap, toAttributeType } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -87,15 +87,26 @@ export function getMetricDetailsQuery(
metricType: MetrictypesTypeDTO | undefined,
filter?: { key: string; value: string },
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
switch (metricType) {
case MetrictypesTypeDTO.sum:
timeAggregation = 'rate';
spaceAggregation = 'sum';
aggregateOperator = 'rate';
if (isNonMonotonicSum) {
timeAggregation = 'avg';
spaceAggregation = 'avg';
aggregateOperator = 'avg';
} else {
timeAggregation = 'rate';
spaceAggregation = 'sum';
aggregateOperator = 'rate';
}
break;
case MetrictypesTypeDTO.gauge:
timeAggregation = 'avg';
@@ -120,6 +131,8 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic);
return {
...initialQueriesMap[DataSource.METRICS],
builder: {
@@ -128,8 +141,8 @@ export function getMetricDetailsQuery(
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
key: metricName,
type: metricType ?? '',
id: `${metricName}----${metricType}---string--`,
type: attributeType,
id: `${metricName}----${attributeType}---string--`,
dataType: DataTypes.String,
},
aggregations: [
@@ -170,6 +183,7 @@ export function getMetricDetailsQuery(
},
]
: [],
...(limit ? { limit } : {}),
},
],
queryFormulas: [],

View File

@@ -1,76 +0,0 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { METRIC_TYPE_LABEL_MAP } from './constants';
// TODO: @amlannandy Delete this component after API migration is complete
function MetricTypeRenderer({ type }: { type: MetricType }): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetricType.SUM:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetricType.GAUGE:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetricType.HISTOGRAM:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetricType.SUMMARY:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetricType.EXPONENTIAL_HISTOGRAM:
return [
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
Color.BG_AQUA_500,
];
default:
return [null, ''];
}
}, [type]);
const metricTypeRendererStyle = useMemo(
() => ({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
}),
[color],
);
const metricTypeRendererTextStyle = useMemo(
() => ({
color,
fontSize: 12,
}),
[color],
);
return (
<div className="metric-type-renderer" style={metricTypeRendererStyle}>
{icon}
<Typography.Text style={metricTypeRendererTextStyle}>
{METRIC_TYPE_LABEL_MAP[type]}
</Typography.Text>
</div>
);
}
export default MetricTypeRenderer;

View File

@@ -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="Search your metrics. Try service.name='api' to see all API service metrics, or http.client for HTTP client metrics."
/>
</div>
<RunQueryBtn

View File

@@ -10,6 +10,7 @@ import {
} from 'antd';
import { SorterResult } from 'antd/es/table/interface';
import { Querybuildertypesv5OrderDirectionDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { Info } from 'lucide-react';
import { MetricsListItemRowData, MetricsTableProps } from './types';
@@ -18,6 +19,7 @@ import { getMetricsTableColumns } from './utils';
function MetricsTable({
isLoading,
isError,
error,
data,
pageSize,
currentPage,
@@ -71,54 +73,54 @@ function MetricsTable({
<Info size={16} />
</Tooltip>
</div>
<Table
loading={{
spinning: isLoading,
indicator: (
<Spin
data-testid="metrics-table-loading-state"
indicator={<LoadingOutlined size={14} spin />}
/>
),
}}
dataSource={data}
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
locale={{
emptyText: isLoading ? null : (
<div
className="no-metrics-message-container"
data-testid={
isError ? 'metrics-table-error-state' : 'metrics-table-empty-state'
}
>
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
{isError && error ? (
<ErrorInPlace error={error} />
) : (
<Table
loading={{
spinning: isLoading,
indicator: (
<Spin
data-testid="metrics-table-loading-state"
indicator={<LoadingOutlined size={14} spin />}
/>
<Typography.Text className="no-metrics-message">
{isError
? 'Error fetching metrics. If the problem persists, please contact support.'
: 'This query had no results. Edit your query and try again!'}
</Typography.Text>
</div>
),
}}
tableLayout="fixed"
onChange={handleTableChange}
pagination={{
current: currentPage,
pageSize,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
total: totalCount,
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => openMetricDetails(record.key, 'list'),
className: 'clickable-row',
})}
/>
),
}}
dataSource={data}
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
locale={{
emptyText: isLoading ? null : (
<div
className="no-metrics-message-container"
data-testid="metrics-table-empty-state"
>
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-metrics-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
),
}}
tableLayout="fixed"
onChange={handleTableChange}
pagination={{
current: currentPage,
pageSize,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
total: totalCount,
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => openMetricDetails(record.key, 'list'),
className: 'clickable-row',
})}
/>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { Group } from '@visx/group';
import { Treemap } from '@visx/hierarchy';
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { HierarchyNode, stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react';
@@ -27,6 +28,7 @@ import {
function MetricsTreemapInternal({
isLoading,
isError,
error,
data,
viewType,
openMetricDetails,
@@ -91,6 +93,10 @@ function MetricsTreemapInternal({
);
}
if (isError && error) {
return <ErrorInPlace error={error} />;
}
if (isError) {
return (
<Empty
@@ -174,6 +180,7 @@ function MetricsTreemap({
data,
isLoading,
isError,
error,
openMetricDetails,
setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
@@ -202,6 +209,7 @@ function MetricsTreemap({
<MetricsTreemapInternal
isLoading={isLoading}
isError={isError}
error={error}
data={data}
viewType={viewType}
openMetricDetails={openMetricDetails}

View File

@@ -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;

View File

@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
useGetMetricsStats,
useGetMetricsTreemap,
@@ -15,13 +16,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,13 +61,23 @@ function Summary(): JSX.Element {
heatmapView,
setHeatmapView,
] = useState<MetricsexplorertypesTreemapModeDTO>(
MetricsexplorertypesTreemapModeDTO.timeseries,
MetricsexplorertypesTreemapModeDTO.samples,
);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
const {
currentQuery,
]);
stagedQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
const query = useMemo(
() =>
stagedQuery?.builder?.queryData?.[0] ||
initialQueriesMap[DataSource.METRICS].builder.queryData[0],
[stagedQuery],
);
const [searchParams, setSearchParams] = useSearchParams();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
@@ -84,10 +94,21 @@ function Summary(): JSX.Element {
(state) => state.globalTime,
);
const appliedFilterExpression = query?.filter?.expression || '';
const [
currentQueryFilterExpression,
setCurrentQueryFilterExpression,
] = useState<string>(query?.filter?.expression || '');
] = useState<string>(appliedFilterExpression);
useEffect(() => {
setCurrentQueryFilterExpression(appliedFilterExpression);
}, [appliedFilterExpression]);
const queryFilterExpression = useMemo(
() => ({ expression: appliedFilterExpression }),
[appliedFilterExpression],
);
useEffect(() => {
logEvent(MetricsExplorerEvents.TabChanged, {
@@ -100,11 +121,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),
@@ -144,6 +160,7 @@ function Summary(): JSX.Element {
mutate: getMetricsStats,
isLoading: isGetMetricsStatsLoading,
isError: isGetMetricsStatsError,
error: metricsStatsError,
} = useGetMetricsStats();
const {
@@ -151,8 +168,19 @@ function Summary(): JSX.Element {
mutate: getMetricsTreemap,
isLoading: isGetMetricsTreemapLoading,
isError: isGetMetricsTreemapError,
error: metricsTreemapError,
} = useGetMetricsTreemap();
const metricsStatsApiError = useMemo(
() => convertToApiError(metricsStatsError),
[metricsStatsError],
);
const metricsTreemapApiError = useMemo(
() => convertToApiError(metricsTreemapError),
[metricsTreemapError],
);
useEffect(() => {
getMetricsStats({
data: metricsListQuery,
@@ -186,7 +214,6 @@ function Summary(): JSX.Element {
],
},
});
setCurrentQueryFilterExpression(expression);
setCurrentPage(1);
if (expression) {
logEvent(MetricsExplorerEvents.FilterApplied, {
@@ -283,10 +310,14 @@ function Summary(): JSX.Element {
};
const isMetricsListDataEmpty =
formattedMetricsData.length === 0 && !isGetMetricsStatsLoading;
formattedMetricsData.length === 0 &&
!isGetMetricsStatsLoading &&
!isGetMetricsStatsError;
const isMetricsTreeMapDataEmpty =
!treeMapData?.data[heatmapView]?.length && !isGetMetricsTreemapLoading;
!treeMapData?.data[heatmapView]?.length &&
!isGetMetricsTreemapLoading &&
!isGetMetricsTreemapError;
const showFullScreenLoading =
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
@@ -305,7 +336,9 @@ function Summary(): JSX.Element {
/>
{showFullScreenLoading ? (
<MetricsLoading />
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
) : isMetricsListDataEmpty &&
isMetricsTreeMapDataEmpty &&
!appliedFilterExpression ? (
<NoLogs dataSource={DataSource.METRICS} />
) : (
<>
@@ -313,6 +346,7 @@ function Summary(): JSX.Element {
data={treeMapData?.data}
isLoading={isGetMetricsTreemapLoading}
isError={isGetMetricsTreemapError}
error={metricsTreemapApiError}
viewType={heatmapView}
openMetricDetails={openMetricDetails}
setHeatmapView={handleSetHeatmapView}
@@ -320,6 +354,7 @@ function Summary(): JSX.Element {
<MetricsTable
isLoading={isGetMetricsStatsLoading}
isError={isGetMetricsStatsError}
error={metricsStatsApiError}
data={formattedMetricsData}
pageSize={pageSize}
currentPage={currentPage}

View File

@@ -1,63 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import MetricTypeRenderer from '../MetricTypeRenderer';
jest.mock('lucide-react', () => {
return {
__esModule: true,
Diff: (): JSX.Element => <svg data-testid="diff-icon" />,
Gauge: (): JSX.Element => <svg data-testid="gauge-icon" />,
BarChart2: (): JSX.Element => <svg data-testid="bar-chart-2-icon" />,
BarChartHorizontal: (): JSX.Element => (
<svg data-testid="bar-chart-horizontal-icon" />
),
BarChart: (): JSX.Element => <svg data-testid="bar-chart-icon" />,
};
});
describe('MetricTypeRenderer', () => {
it('should render correct icon and color for each metric type', () => {
const types = [
{
type: MetricType.SUM,
color: Color.BG_ROBIN_500,
iconTestId: 'diff-icon',
},
{
type: MetricType.GAUGE,
color: Color.BG_SAKURA_500,
iconTestId: 'gauge-icon',
},
{
type: MetricType.HISTOGRAM,
color: Color.BG_SIENNA_500,
iconTestId: 'bar-chart-2-icon',
},
{
type: MetricType.SUMMARY,
color: Color.BG_FOREST_500,
iconTestId: 'bar-chart-horizontal-icon',
},
{
type: MetricType.EXPONENTIAL_HISTOGRAM,
color: Color.BG_AQUA_500,
iconTestId: 'bar-chart-icon',
},
];
types.forEach(({ type, color, iconTestId }) => {
const { container } = render(<MetricTypeRenderer type={type} />);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv).toHaveStyle({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
});
expect(screen.getByTestId(iconTestId)).toBeInTheDocument();
});
});
});

View File

@@ -6,6 +6,7 @@ import { Filter } from 'api/v5/v5';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import store from 'store';
import APIError from 'types/api/error';
import MetricsTable from '../MetricsTable';
import { MetricsListItemRowData } from '../types';
@@ -119,12 +120,23 @@ describe('MetricsTable', () => {
});
it('shows error state', () => {
const mockError = new APIError({
httpStatusCode: 400,
error: {
code: '400',
message: 'invalid filter expression',
url: '',
errors: [],
},
});
render(
<MemoryRouter>
<Provider store={store}>
<MetricsTable
isLoading={false}
isError
error={mockError}
data={[]}
pageSize={10}
currentPage={1}
@@ -139,12 +151,8 @@ describe('MetricsTable', () => {
</MemoryRouter>,
);
expect(screen.getByTestId('metrics-table-error-state')).toBeInTheDocument();
expect(
screen.getByText(
'Error fetching metrics. If the problem persists, please contact support.',
),
).toBeInTheDocument();
expect(screen.getByText('400')).toBeInTheDocument();
expect(screen.getByText('invalid filter expression')).toBeInTheDocument();
});
it('shows empty state when no data', () => {

View File

@@ -1,16 +1,12 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as metricsHooks from 'api/generated/services/metrics';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
import * as useGetMetricsTreeMapHooks from 'hooks/metricsExplorer/useGetMetricsTreeMap';
import store from 'store';
import { render, screen } from 'tests/test-utils';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import { render, screen, waitFor } from 'tests/test-utils';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import Summary from '../Summary';
import { TreemapViewType } from '../types';
jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({
@@ -44,58 +40,135 @@ jest.mock('react-router-dom', () => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
// so filter expression assertions easy
jest.mock('../MetricsSearch', () => {
return function MockMetricsSearch(props: {
currentQueryFilterExpression: string;
}): JSX.Element {
return (
<div data-testid="metrics-search-expression">
{props.currentQueryFilterExpression}
</div>
);
};
});
const queryClient = new QueryClient();
const mockMetricName = 'test-metric';
jest.spyOn(useGetMetricsListHooks, 'useGetMetricsList').mockReturnValue({
data: {
payload: {
status: 'success',
data: {
metrics: [
{
metric_name: mockMetricName,
description: 'description for a test metric',
type: MetricType.GAUGE,
unit: 'count',
lastReceived: '1715702400',
[TreemapViewType.TIMESERIES]: 100,
[TreemapViewType.SAMPLES]: 100,
},
],
},
},
},
isError: false,
isLoading: false,
} as any);
jest.spyOn(useGetMetricsTreeMapHooks, 'useGetMetricsTreeMap').mockReturnValue({
data: {
payload: {
status: 'success',
data: {
[TreemapViewType.TIMESERIES]: [
{
metric_name: mockMetricName,
percentage: 100,
total_value: 100,
},
],
[TreemapViewType.SAMPLES]: [
{
metric_name: mockMetricName,
percentage: 100,
},
],
},
},
},
isError: false,
isLoading: false,
} as any);
const mockSetSearchParams = jest.fn();
const mockGetMetricsStats = jest.fn();
const mockGetMetricsTreemap = jest.fn();
const mockUseQueryBuilderData = {
handleRunQuery: jest.fn(),
stagedQuery: initialQueriesMap[DataSource.METRICS],
updateAllQueriesOperators: jest.fn(),
currentQuery: initialQueriesMap[DataSource.METRICS],
resetQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(),
handleSetQueryData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
isDefaultQuery: jest.fn(),
};
const useGetMetricsStatsSpy = jest.spyOn(metricsHooks, 'useGetMetricsStats');
const useGetMetricsTreemapSpy = jest.spyOn(
metricsHooks,
'useGetMetricsTreemap',
);
const useQueryBuilderSpy = jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder');
describe('Summary', () => {
beforeEach(() => {
jest.clearAllMocks();
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams(),
mockSetSearchParams,
]);
useGetMetricsStatsSpy.mockReturnValue({
data: null,
mutate: mockGetMetricsStats,
isLoading: true,
isError: false,
error: null,
isIdle: true,
isSuccess: false,
reset: jest.fn(),
status: 'idle',
} as any);
useGetMetricsTreemapSpy.mockReturnValue({
data: null,
mutate: mockGetMetricsTreemap,
isLoading: true,
isError: false,
error: null,
isIdle: true,
isSuccess: false,
reset: jest.fn(),
status: 'idle',
} as any);
useQueryBuilderSpy.mockReturnValue(({
...mockUseQueryBuilderData,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
});
it('does not carry filter expression from a previous page', async () => {
const staleFilterExpression = "service.name = 'redis'";
// prev filter from logs explorer
const staleQuery = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
filter: { expression: staleFilterExpression },
},
],
},
};
// stagedQuery has stale filter (before QueryBuilder resets it)
useQueryBuilderSpy.mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: staleQuery,
currentQuery: staleQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
const { rerender } = render(<Summary />);
expect(screen.getByTestId('metrics-search-expression')).toHaveTextContent(
staleFilterExpression,
);
// QB route change effect resets stagedQuery to null
useQueryBuilderSpy.mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: null,
currentQuery: initialQueriesMap[DataSource.METRICS],
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
rerender(<Summary />);
await waitFor(() => {
expect(
screen.getByTestId('metrics-search-expression'),
).toBeEmptyDOMElement();
});
});
it('persists inspect modal open state across page refresh', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
@@ -105,13 +178,7 @@ describe('Summary', () => {
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
render(<Summary />);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});
@@ -120,18 +187,12 @@ describe('Summary', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
isMetricDetailsOpen: 'true',
selectedMetricName: mockMetricName,
selectedMetricName: 'test-metric',
}),
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
render(<Summary />);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});

View File

@@ -2,7 +2,6 @@ import {
MetricsexplorertypesTreemapModeDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
export const METRICS_TABLE_PAGE_SIZE = 10;
@@ -19,15 +18,6 @@ export const TREEMAP_SQUARE_PADDING = 5;
export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 };
// TODO: Remove this once API migration is complete
export const METRIC_TYPE_LABEL_MAP = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
[MetricType.HISTOGRAM]: 'Histogram',
[MetricType.SUMMARY]: 'Summary',
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
};
export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',
@@ -36,15 +26,6 @@ export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.exponentialhistogram]: 'Exp. Histogram',
};
// TODO(@amlannandy): To remove this once API migration is complete
export const METRIC_TYPE_VALUES_MAP: Record<MetricType, string> = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
[MetricType.HISTOGRAM]: 'Histogram',
[MetricType.SUMMARY]: 'Summary',
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
};
export const METRIC_TYPE_VIEW_VALUES_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',

View File

@@ -5,11 +5,13 @@ import {
Querybuildertypesv5OrderByDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Filter } from 'api/v5/v5';
import APIError from 'types/api/error';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsTableProps {
isLoading: boolean;
isError: boolean;
error?: APIError;
data: MetricsListItemRowData[];
pageSize: number;
currentPage: number;
@@ -33,6 +35,7 @@ export interface MetricsTreemapProps {
data: MetricsexplorertypesTreemapResponseDTO | undefined;
isLoading: boolean;
isError: boolean;
error?: APIError;
viewType: MetricsexplorertypesTreemapModeDTO;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
setHeatmapView: (value: MetricsexplorertypesTreemapModeDTO) => void;
@@ -41,6 +44,7 @@ export interface MetricsTreemapProps {
export interface MetricsTreemapInternalProps {
isLoading: boolean;
isError: boolean;
error?: APIError;
data: MetricsexplorertypesTreemapResponseDTO | undefined;
viewType: MetricsexplorertypesTreemapModeDTO;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;

View File

@@ -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}
/>

View File

@@ -0,0 +1,6 @@
.metric-name-selector {
.ant-select-selection-placeholder {
color: var(--bg-slate-200);
font-style: italic;
}
}

View File

@@ -0,0 +1,995 @@
import { useEffect, useState } from 'react';
import {
fireEvent,
render,
screen,
waitFor,
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();
});
it('preserves metric search text for signalSource normalization transition (undefined -> empty)', async () => {
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
const query = makeQuery({
aggregateAttribute: {
key: 'http_requests_total',
type: '',
dataType: DataTypes.Float64,
},
aggregations: [
{
metricName: 'http_requests_total',
timeAggregation: 'rate',
spaceAggregation: 'sum',
temporality: '',
},
] as MetricAggregation[],
});
const { rerender } = render(
<MetricNameSelector
query={query}
onChange={jest.fn()}
signalSource={undefined}
/>,
);
rerender(
<MetricNameSelector query={query} onChange={jest.fn()} signalSource="" />,
);
await waitFor(() => {
const lastCall =
mockUseListMetrics.mock.calls[mockUseListMetrics.mock.calls.length - 1];
expect(lastCall?.[0]).toMatchObject({
searchText: 'http_requests_total',
limit: 100,
});
});
});
it('updates search text when metric name is hydrated after initial mount', async () => {
returnMetrics([makeMetric({ metricName: 'signoz_latency.bucket' })]);
const emptyQuery = makeQuery({
aggregateAttribute: {
key: '',
type: '',
dataType: DataTypes.Float64,
},
aggregations: [
{
metricName: '',
timeAggregation: 'rate',
spaceAggregation: 'sum',
temporality: '',
},
] as MetricAggregation[],
});
const hydratedQuery = makeQuery({
aggregateAttribute: {
key: '',
type: '',
dataType: DataTypes.Float64,
},
aggregations: [
{
metricName: 'signoz_latency.bucket',
timeAggregation: 'rate',
spaceAggregation: 'sum',
temporality: '',
},
] as MetricAggregation[],
});
const { rerender } = render(
<MetricNameSelector
query={emptyQuery}
onChange={jest.fn()}
signalSource=""
/>,
);
rerender(
<MetricNameSelector
query={hydratedQuery}
onChange={jest.fn()}
signalSource=""
/>,
);
await waitFor(() => {
const lastCall =
mockUseListMetrics.mock.calls[mockUseListMetrics.mock.calls.length - 1];
expect(lastCall?.[0]).toMatchObject({
searchText: 'signoz_latency.bucket',
limit: 100,
});
});
});
});
describe('selecting a metric type updates the aggregation options', () => {
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 P50P99 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 P50P99 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',
]);
});
});

View File

@@ -0,0 +1,289 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { AutoComplete, Spin, Typography } from 'antd';
import { useListMetrics } from 'api/generated/services/metrics';
import { MetricsexplorertypesListMetricDTO } from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES, toAttributeType } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import './MetricNameSelector.styles.scss';
export type MetricNameSelectorProps = {
query: IBuilderQuery;
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
disabled?: boolean;
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
signalSource?: 'meter' | '';
};
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
return toAttributeType(metric.type, metric.isMonotonic);
}
function createAutocompleteData(
metricName: string,
type: string,
): BaseAutocompleteData {
return { key: metricName, type, dataType: DataTypes.Float64 };
}
// N.B on the metric name selector behaviour.
//
// Metric aggregation options resolution:
// The component maintains a ref (metricsRef) of the latest API results.
// When the user commits a metric name (via dropdown select, blur, or Cmd+Enter),
// resolveMetricFromText looks up the metric in metricsRef to determine its type
// (Sum, Gauge, Histogram, etc.). If the metric isn't found (e.g. the user typed
// a name before the debounced search returned), the type is empty and downstream
// treats it as unknown.
//
// Selection handling:
// - Dropdown select: user picks from the dropdown; type is always resolved
// since the option came from the current API results.
// - Blur: user typed a name and tabbed/clicked away without selecting from
// the dropdown. If the name differs from the current metric, it's resolved
// and committed. If the input is empty, it resets to the current metric name.
// - Cmd/Ctrl+Enter: resolves the typed name and commits it using flushSync
// so the state update is processed synchronously before QueryBuilderV2's
// onKeyDownCapture fires handleRunQuery. Uses document-level capture phase
// to run before React's root-level event dispatch. However, there is still one
// need to be handled here. TODO(srikanthccv): enter before n/w req completion
//
// Edit mode:
// When a saved query is loaded, the metric name may be set via aggregations
// but aggregateAttribute.type may be missing. Once the API returns metric data,
// the component calls onChange with isEditMode=true to backfill the type without
// resetting aggregation options.
//
// Signal source:
// When signalSource is 'meter', the API is filtered to meter metrics only.
// Changing signalSource clears the input and search text.
export const MetricNameSelector = memo(function MetricNameSelector({
query,
onChange,
disabled,
defaultValue,
onSelect,
signalSource,
}: MetricNameSelectorProps): JSX.Element {
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
'';
const [inputValue, setInputValue] = useState<string>(
currentMetricName || defaultValue || '',
);
const [searchText, setSearchText] = useState<string>(currentMetricName);
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
const selectedFromDropdownRef = useRef(false);
const prevSignalSourceRef = useRef(signalSource);
useEffect(() => {
setInputValue(currentMetricName || defaultValue || '');
if (currentMetricName) {
setSearchText(currentMetricName);
}
}, [defaultValue, currentMetricName]);
useEffect(() => {
if (prevSignalSourceRef.current !== signalSource) {
const previousSignalSource = prevSignalSourceRef.current;
prevSignalSourceRef.current = signalSource;
const isNormalizationTransition =
(previousSignalSource === undefined && signalSource === '') ||
(previousSignalSource === '' && signalSource === undefined);
if (isNormalizationTransition && currentMetricName) {
setSearchText(currentMetricName);
setInputValue(currentMetricName || defaultValue || '');
return;
}
setSearchText('');
setInputValue('');
}
}, [signalSource, currentMetricName, defaultValue]);
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 ||
query.aggregateAttribute?.key;
const hasAggregateAttributeType = query.aggregateAttribute?.type;
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
const found = metrics.find((m) => m.metricName === metricName);
if (found) {
onChange(
createAutocompleteData(found.metricName, getAttributeType(found)),
true,
);
}
}
}, [
metrics,
query.aggregations,
query.aggregateAttribute?.key,
query.aggregateAttribute?.type,
onChange,
]);
const resolveMetricFromText = useCallback(
(text: string): BaseAutocompleteData => {
const found = metricsRef.current.find((m) => m.metricName === text);
if (found) {
return createAutocompleteData(found.metricName, getAttributeType(found));
}
return createAutocompleteData(text, '');
},
[],
);
const placeholder = useMemo(() => {
if (signalSource === 'meter') {
return 'Search for a meter metric...';
}
return 'Search for a metric...';
}, [signalSource]);
const handleChange = useCallback((value: string): void => {
setInputValue(value);
}, []);
const handleSearch = useCallback((value: string): void => {
setSearchText(value);
selectedFromDropdownRef.current = false;
}, []);
const handleSelect = useCallback(
(value: string): void => {
selectedFromDropdownRef.current = true;
const resolved = resolveMetricFromText(value);
onChange(resolved);
if (onSelect) {
onSelect(resolved);
}
setSearchText('');
},
[onChange, onSelect, resolveMetricFromText],
);
const handleBlur = useCallback(() => {
if (selectedFromDropdownRef.current) {
selectedFromDropdownRef.current = false;
return;
}
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
onChange(resolveMetricFromText(typedValue));
} else if (!typedValue && currentMetricName) {
setInputValue(currentMetricName);
}
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
flushSync(() => {
onChange(resolveMetricFromText(typedValue));
});
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
return (): void => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}
onSearch={handleSearch}
onChange={handleChange}
notFoundContent={
isFetching ? (
<Spin size="small" />
) : isError ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
Failed to load metrics
</Typography.Text>
) : null
}
options={optionsData}
value={inputValue}
onBlur={handleBlur}
onSelect={handleSelect}
disabled={disabled}
/>
);
});

View File

@@ -0,0 +1,2 @@
export type { MetricNameSelectorProps } from './MetricNameSelector';
export { MetricNameSelector } from './MetricNameSelector';

View File

@@ -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';

View File

@@ -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 &&

View File

@@ -1,50 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getMetricsTreeMap,
MetricsTreeMapPayload,
MetricsTreeMapResponse,
} from 'api/metricsExplorer/getMetricsTreeMap';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricsTreeMap = (
requestData: MetricsTreeMapPayload,
options?: UseQueryOptions<
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
Error
>;
export const useGetMetricsTreeMap: UseGetMetricsTreeMap = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_METRICS_TREE_MAP, requestData];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) => getMetricsTreeMap(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,26 +0,0 @@
import { useMutation, UseMutationResult } from 'react-query';
import updateMetricMetadata, {
UpdateMetricMetadataProps,
UpdateMetricMetadataResponse,
} from 'api/metricsExplorer/updateMetricMetadata';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface UseUpdateMetricMetadataProps {
metricName: string;
payload: UpdateMetricMetadataProps;
}
export function useUpdateMetricMetadata(): UseMutationResult<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
> {
return useMutation<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
>({
mutationFn: ({ metricName, payload }) =>
updateMetricMetadata(metricName, payload),
});
}

View File

@@ -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,
},
];
}
}
}
}

View File

@@ -20,6 +20,7 @@ export const useHandleExplorerTabChange = (): {
type: string,
querySearchParameters?: ICurrentQueryData,
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
newTab?: boolean,
) => void;
} => {
const {
@@ -63,6 +64,7 @@ export const useHandleExplorerTabChange = (): {
type: string,
currentQueryData?: ICurrentQueryData,
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
newTab?: boolean,
) => {
const newPanelType = type as PANEL_TYPES;
@@ -81,13 +83,21 @@ export const useHandleExplorerTabChange = (): {
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
},
redirectToUrl,
undefined,
newTab,
);
} else {
redirectWithQueryBuilderData(query, {
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
});
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
},
undefined,
undefined,
newTab,
);
}
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],

View File

@@ -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,
},

View File

@@ -4,6 +4,7 @@ import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { TooltipProps } from '../types';
@@ -22,6 +23,14 @@ export default function Tooltip({
const isDarkMode = useIsDarkMode();
const [listHeight, setListHeight] = useState(0);
const tooltipContent = content ?? [];
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = useMemo(() => {
if (!timezone) {
return userTimezone.value;
}
return timezone.value;
}, [timezone, userTimezone]);
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
@@ -33,10 +42,10 @@ export default function Tooltip({
return null;
}
return dayjs(data[0][cursorIdx] * 1000)
.tz(timezone)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
timezone,
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,

View File

@@ -83,7 +83,7 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
const defaultProps: TooltipTestProps = {
uPlotInstance: createUPlotInstance(null),
timezone: 'UTC',
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
content: [],
showTooltipHeader: true,
// TooltipRenderArgs (not used directly in component but required by type)

View File

@@ -92,7 +92,7 @@ export default function UPlotChart({
setPlotContextInitialState({
uPlotInstance: plot,
widgetId: config.getWidgetId(),
id: config.getId(),
shouldSaveSelectionPreference: config.getShouldSaveSelectionPreference(),
});

View File

@@ -84,7 +84,7 @@ const createMockConfig = (): UPlotConfigBuilder => {
hooks: {},
cursor: {},
}),
getWidgetId: jest.fn().mockReturnValue(undefined),
getId: jest.fn().mockReturnValue(undefined),
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
} as unknown) as UPlotConfigBuilder;
};

View File

@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
@@ -61,7 +62,7 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
timezone: string;
timezone?: Timezone;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];

View File

@@ -74,23 +74,21 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private tzDate: ((timestamp: number) => Date) | undefined;
private widgetId: string | undefined;
private id: string;
private onDragSelect: (startTime: number, endTime: number) => void;
constructor(args?: ConfigBuilderProps) {
super(args ?? {});
constructor(args: ConfigBuilderProps) {
super(args);
const {
widgetId,
id,
onDragSelect,
tzDate,
selectionPreferencesSource,
shouldSaveSelectionPreference,
stepInterval,
} = args ?? {};
if (widgetId) {
this.widgetId = widgetId;
}
this.id = id;
if (tzDate) {
this.tzDate = tzDate;
@@ -252,10 +250,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
*/
private getStoredVisibility(): SeriesVisibilityItem[] | null {
if (
this.widgetId &&
this.id &&
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
) {
return getStoredSeriesVisibility(this.widgetId);
return getStoredSeriesVisibility(this.id);
}
return null;
}
@@ -378,10 +376,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
}
/**
* Get the widget id
* Get the id for the builder
*/
getWidgetId(): string | undefined {
return this.widgetId;
getId(): string {
return this.id;
}
/**

View File

@@ -1,4 +1,3 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
@@ -23,6 +22,9 @@ import {
* Path builders are static and shared across all instances of UPlotSeriesBuilder
*/
let builders: PathBuilders | null = null;
const DEFAULT_LINE_WIDTH = 2;
export const POINT_SIZE_FACTOR = 2.5;
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
constructor(props: SeriesProps) {
super(props);
@@ -53,7 +55,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
const lineConfig: Partial<Series> = {
stroke: resolvedLineColor,
width: lineWidth ?? 2,
width: lineWidth ?? DEFAULT_LINE_WIDTH,
};
if (lineStyle === LineStyle.Dashed) {
@@ -66,9 +68,9 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
if (fillColor) {
lineConfig.fill = fillColor;
} else if (this.props.panelType === PANEL_TYPES.BAR) {
} else if (this.props.drawStyle === DrawStyle.Bar) {
lineConfig.fill = resolvedLineColor;
} else if (this.props.panelType === PANEL_TYPES.HISTOGRAM) {
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${resolvedLineColor}40`;
}
@@ -137,10 +139,19 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
drawStyle,
showPoints,
} = this.props;
/**
* If pointSize is not provided, use the lineWidth * POINT_SIZE_FACTOR
* to determine the point size.
* POINT_SIZE_FACTOR is 2, so the point size will be 2x the line width.
*/
const resolvedPointSize =
pointSize ?? (lineWidth ?? DEFAULT_LINE_WIDTH) * POINT_SIZE_FACTOR;
const pointsConfig: Partial<Series.Points> = {
stroke: resolvedLineColor,
fill: resolvedLineColor,
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
size: resolvedPointSize,
filter: pointsFilter || undefined,
};
@@ -231,7 +242,7 @@ function getPathBuilder({
throw new Error('Required uPlot path builders are not available');
}
if (drawStyle === DrawStyle.Bar) {
if (drawStyle === DrawStyle.Bar || drawStyle === DrawStyle.Histogram) {
const pathBuilders = uPlot.paths;
return getBarPathBuilder({
pathBuilders,

View File

@@ -1,4 +1,3 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot from 'uplot';
import {
@@ -43,27 +42,27 @@ describe('UPlotConfigBuilder', () => {
label: 'Requests',
colorMapping: {},
drawStyle: DrawStyle.Line,
panelType: PANEL_TYPES.TIME_SERIES,
...overrides,
});
it('returns correct save selection preference flag from constructor args', () => {
const builder = new UPlotConfigBuilder({
id: 'widget-123',
shouldSaveSelectionPreference: true,
});
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
});
it('returns widgetId from constructor args', () => {
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
it('returns id from constructor args', () => {
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
expect(builder.getWidgetId()).toBe('widget-123');
expect(builder.getId()).toBe('widget-123');
});
it('sets tzDate from constructor and includes it in config', () => {
const tzDate = (ts: number): Date => new Date(ts);
const builder = new UPlotConfigBuilder({ tzDate });
const builder = new UPlotConfigBuilder({ id: 'widget-123', tzDate });
const config = builder.getConfig();
@@ -72,7 +71,7 @@ describe('UPlotConfigBuilder', () => {
it('does not call onDragSelect for click without drag (width === 0)', () => {
const onDragSelect = jest.fn();
const builder = new UPlotConfigBuilder({ onDragSelect });
const builder = new UPlotConfigBuilder({ id: 'widget-123', onDragSelect });
const config = builder.getConfig();
const setSelectHooks = config.hooks?.setSelect ?? [];
@@ -85,14 +84,15 @@ describe('UPlotConfigBuilder', () => {
// Simulate uPlot calling the hook
const setSelectHook = setSelectHooks[0];
setSelectHook!(uplotInstance);
expect(setSelectHook).toBeDefined();
setSelectHook?.(uplotInstance);
expect(onDragSelect).not.toHaveBeenCalled();
});
it('calls onDragSelect with start and end times in milliseconds for a drag selection', () => {
const onDragSelect = jest.fn();
const builder = new UPlotConfigBuilder({ onDragSelect });
const builder = new UPlotConfigBuilder({ id: 'widget-123', onDragSelect });
const config = builder.getConfig();
const setSelectHooks = config.hooks?.setSelect ?? [];
@@ -111,7 +111,8 @@ describe('UPlotConfigBuilder', () => {
} as unknown) as uPlot;
const setSelectHook = setSelectHooks[0];
setSelectHook!(uplotInstance);
expect(setSelectHook).toBeDefined();
setSelectHook?.(uplotInstance);
expect(onDragSelect).toHaveBeenCalledTimes(1);
// 100 and 110 seconds converted to milliseconds
@@ -119,7 +120,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const drawHook = jest.fn();
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
@@ -134,7 +135,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds axes, scales, and series and wires them into the final config', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
// Add axis and scale
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
@@ -170,7 +171,7 @@ describe('UPlotConfigBuilder', () => {
});
it('merges axis when addAxis is called twice with same scaleKey', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
@@ -183,7 +184,7 @@ describe('UPlotConfigBuilder', () => {
});
it('merges scale when addScale is called twice with same scaleKey', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
builder.addScale({ scaleKey: 'y', min: 0 });
builder.addScale({ scaleKey: 'y', max: 100 });
@@ -204,7 +205,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
widgetId: 'widget-1',
id: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -231,7 +232,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
widgetId: 'widget-1',
id: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -269,7 +270,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
widgetId: 'widget-1',
id: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -302,7 +303,7 @@ describe('UPlotConfigBuilder', () => {
]);
const builder = new UPlotConfigBuilder({
widgetId: 'widget-dup',
id: 'widget-dup',
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
});
@@ -329,7 +330,7 @@ describe('UPlotConfigBuilder', () => {
it('does not attempt to read stored visibility when using in-memory preferences', () => {
const builder = new UPlotConfigBuilder({
widgetId: 'widget-1',
id: 'widget-1',
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
});
@@ -344,7 +345,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds thresholds only once per scale key', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const thresholdsOptions = {
scaleKey: 'y',
@@ -362,7 +363,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds multiple thresholds when scale key is different', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const thresholdsOptions = {
scaleKey: 'y',
@@ -383,7 +384,7 @@ describe('UPlotConfigBuilder', () => {
});
it('merges cursor configuration with defaults instead of replacing them', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
builder.setCursor({
drag: { setScale: false },
@@ -398,7 +399,7 @@ describe('UPlotConfigBuilder', () => {
describe('getCursorConfig', () => {
it('returns default cursor merged with custom cursor when no stepInterval', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
builder.setCursor({
drag: { setScale: false },
@@ -412,7 +413,7 @@ describe('UPlotConfigBuilder', () => {
});
it('returns hover prox as DEFAULT_HOVER_PROXIMITY_VALUE when stepInterval is not set', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const cursorConfig = builder.getCursorConfig();
@@ -424,15 +425,15 @@ describe('UPlotConfigBuilder', () => {
const mockWidth = 100;
calculateWidthBasedOnStepIntervalMock.mockReturnValue(mockWidth);
const builder = new UPlotConfigBuilder({ stepInterval });
const builder = new UPlotConfigBuilder({ id: 'widget-123', stepInterval });
const cursorConfig = builder.getCursorConfig();
expect(typeof cursorConfig.hover?.prox).toBe('function');
const uPlotInstance = {} as uPlot;
const proxResult = (cursorConfig.hover!.prox as (u: uPlot) => number)(
uPlotInstance,
);
const prox = cursorConfig.hover?.prox as ((u: uPlot) => number) | undefined;
expect(prox).toBeDefined();
const proxResult = prox ? prox(uPlotInstance) : NaN;
expect(calculateWidthBasedOnStepIntervalMock).toHaveBeenCalledWith({
uPlotInstance,
@@ -443,7 +444,7 @@ describe('UPlotConfigBuilder', () => {
});
it('adds plugins and includes them in config', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const plugin: uPlot.Plugin = {
opts: (): void => {},
hooks: {},
@@ -458,7 +459,7 @@ describe('UPlotConfigBuilder', () => {
it('sets padding, legend, focus, select, tzDate, bands and includes them in config', () => {
const tzDate = (ts: number): Date => new Date(ts);
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
@@ -480,7 +481,7 @@ describe('UPlotConfigBuilder', () => {
});
it('does not include plugins when none added', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const config = builder.getConfig();
@@ -488,7 +489,7 @@ describe('UPlotConfigBuilder', () => {
});
it('does not include bands when empty', () => {
const builder = new UPlotConfigBuilder();
const builder = new UPlotConfigBuilder({ id: 'widget-123' });
const config = builder.getConfig();

View File

@@ -1,4 +1,3 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
@@ -9,7 +8,7 @@ import {
LineStyle,
VisibilityMode,
} from '../types';
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
const createBaseProps = (
overrides: Partial<SeriesProps> = {},
@@ -19,7 +18,6 @@ const createBaseProps = (
colorMapping: {},
drawStyle: DrawStyle.Line,
isDarkMode: false,
panelType: PANEL_TYPES.TIME_SERIES,
...overrides,
});
@@ -137,7 +135,6 @@ describe('UPlotSeriesBuilder', () => {
const smallPointsBuilder = new UPlotSeriesBuilder(
createBaseProps({
lineWidth: 4,
pointSize: 2,
}),
);
const largePointsBuilder = new UPlotSeriesBuilder(
@@ -150,7 +147,7 @@ describe('UPlotSeriesBuilder', () => {
const smallConfig = smallPointsBuilder.getConfig();
const largeConfig = largePointsBuilder.getConfig();
expect(smallConfig.points?.size).toBeUndefined();
expect(smallConfig.points?.size).toBe(4 * POINT_SIZE_FACTOR); // should be lineWidth * POINT_SIZE_FACTOR, when pointSize is not provided
expect(largeConfig.points?.size).toBe(4);
});

View File

@@ -34,7 +34,7 @@ export enum SelectionPreferencesSource {
* Props for configuring the uPlot config builder
*/
export interface ConfigBuilderProps {
widgetId?: string;
id: string;
onDragSelect?: (startTime: number, endTime: number) => void;
tzDate?: uPlot.LocalDateFromUnix;
selectionPreferencesSource?: SelectionPreferencesSource;
@@ -112,6 +112,7 @@ export enum DrawStyle {
Line = 'line',
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
}
export enum LineInterpolation {
@@ -168,7 +169,6 @@ export interface PointsConfig {
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
scaleKey: string;
label?: string;
panelType: PANEL_TYPES;
colorMapping: Record<string, string>;
drawStyle: DrawStyle;
pathBuilder?: Series.PathBuilder;

View File

@@ -13,7 +13,7 @@ import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContain
import type uPlot from 'uplot';
export interface PlotContextInitialState {
uPlotInstance: uPlot | null;
widgetId?: string;
id?: string;
shouldSaveSelectionPreference?: boolean;
}
export interface IPlotContext {
@@ -31,17 +31,17 @@ export const PlotContextProvider = ({
}: PropsWithChildren): JSX.Element => {
const uPlotInstanceRef = useRef<uPlot | null>(null);
const activeSeriesIndex = useRef<number | undefined>(undefined);
const widgetIdRef = useRef<string | undefined>(undefined);
const idRef = useRef<string | undefined>(undefined);
const shouldSavePreferencesRef = useRef<boolean>(false);
const setPlotContextInitialState = useCallback(
({
uPlotInstance,
widgetId,
id,
shouldSaveSelectionPreference,
}: PlotContextInitialState): void => {
uPlotInstanceRef.current = uPlotInstance;
widgetIdRef.current = widgetId;
idRef.current = id;
activeSeriesIndex.current = undefined;
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
},
@@ -50,7 +50,7 @@ export const PlotContextProvider = ({
const syncSeriesVisibilityToLocalStorage = useCallback((): void => {
const plot = uPlotInstanceRef.current;
if (!plot || !widgetIdRef.current) {
if (!plot || !idRef.current) {
return;
}
@@ -61,7 +61,7 @@ export const PlotContextProvider = ({
}),
);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
}, []);
const onToggleSeriesVisibility = useCallback(
@@ -84,7 +84,7 @@ export const PlotContextProvider = ({
show: isReset || currentSeriesIndex === seriesIndex,
});
});
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
});
@@ -104,7 +104,7 @@ export const PlotContextProvider = ({
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
},

View File

@@ -32,13 +32,13 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
interface TestComponentProps {
plot?: uPlot;
widgetId?: string;
id?: string;
shouldSaveSelectionPreference?: boolean;
}
const TestComponent = ({
plot,
widgetId,
id,
shouldSaveSelectionPreference,
}: TestComponentProps): JSX.Element => {
const {
@@ -49,17 +49,13 @@ const TestComponent = ({
onFocusSeries,
} = usePlotContext();
const handleInit = (): void => {
if (
!plot ||
!widgetId ||
typeof shouldSaveSelectionPreference !== 'boolean'
) {
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
return;
}
setPlotContextInitialState({
uPlotInstance: plot,
widgetId,
id,
shouldSaveSelectionPreference,
});
};
@@ -148,11 +144,7 @@ describe('PlotContext', () => {
render(
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-123"
shouldSaveSelectionPreference
/>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
@@ -199,7 +191,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-visibility"
id="widget-visibility"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -240,7 +232,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-reset"
id="widget-reset"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -290,7 +282,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-toggle"
id="widget-toggle"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -316,7 +308,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-missing-series"
id="widget-missing-series"
shouldSaveSelectionPreference
/>
</PlotContextProvider>,
@@ -341,7 +333,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-no-persist"
id="widget-no-persist"
shouldSaveSelectionPreference={false}
/>
</PlotContextProvider>,
@@ -379,7 +371,7 @@ describe('PlotContext', () => {
<PlotContextProvider>
<TestComponent
plot={plot}
widgetId="widget-focus"
id="widget-focus"
shouldSaveSelectionPreference={false}
/>
</PlotContextProvider>,

View File

@@ -40,7 +40,7 @@ class TestConfigBuilder extends UPlotConfigBuilder {
type ConfigMock = TestConfigBuilder;
function createConfigMock(): ConfigMock {
return new TestConfigBuilder();
return new TestConfigBuilder({ id: 'test-widget' });
}
function getHandler(config: ConfigMock, hookName: string): HookHandler {

View File

@@ -25,7 +25,7 @@ function resetStore(): void {
function mockContext(overrides: Partial<VariableFetchContext> = {}): void {
getVariableDependencyContextSpy.mockReturnValue({
doAllVariablesHaveValuesSelected: false,
doAllQueryVariablesHaveValuesSelected: false,
variableTypes: {},
dynamicVariableOrder: [],
dependencyData: null,
@@ -175,9 +175,9 @@ describe('variableFetchStore', () => {
expect(storeSnapshot.cycleIds.b).toBe(1);
});
it('should set dynamic variables to waiting when not all variables have values', () => {
it('should set dynamic variables to waiting when not all query variables have values', () => {
mockContext({
doAllVariablesHaveValuesSelected: false,
doAllQueryVariablesHaveValuesSelected: false,
dependencyData: buildDependencyData({ order: [] }),
variableTypes: { dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
@@ -190,9 +190,9 @@ describe('variableFetchStore', () => {
expect(storeSnapshot.states.dyn1).toBe('waiting');
});
it('should set dynamic variables to loading when all variables have values', () => {
it('should set dynamic variables to loading when all query variables have values', () => {
mockContext({
doAllVariablesHaveValuesSelected: true,
doAllQueryVariablesHaveValuesSelected: true,
dependencyData: buildDependencyData({ order: [] }),
variableTypes: { dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
@@ -523,5 +523,77 @@ describe('variableFetchStore', () => {
expect(variableFetchStore.getSnapshot().states.b).toBe('revalidating');
});
it('should enqueue dynamic variables immediately when all query variables are settled', () => {
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { customVar: [] },
parentDependencyGraph: {},
}),
variableTypes: { q1: 'QUERY', customVar: 'CUSTOM', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.q1 = 'idle';
d.states.customVar = 'idle';
d.states.dyn1 = 'idle';
});
enqueueDescendantsOfVariable('customVar');
const snapshot = variableFetchStore.getSnapshot();
expect(snapshot.states.dyn1).toBe('loading');
expect(snapshot.cycleIds.dyn1).toBe(1);
});
it('should set dynamic variables to waiting when query variables are not yet settled', () => {
// a is a query variable still loading; changing customVar should queue dyn1 as waiting
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { customVar: [] },
parentDependencyGraph: {},
}),
variableTypes: { a: 'QUERY', customVar: 'CUSTOM', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.a = 'loading';
d.states.customVar = 'idle';
d.states.dyn1 = 'idle';
});
enqueueDescendantsOfVariable('customVar');
expect(variableFetchStore.getSnapshot().states.dyn1).toBe('waiting');
});
it('should set dynamic variables to waiting when a query descendant is now loading', () => {
// a -> b (QUERY), dyn1 (DYNAMIC). When a changes, b starts loading,
// so dyn1 should wait until b settles.
mockContext({
dependencyData: buildDependencyData({
transitiveDescendants: { a: ['b'] },
parentDependencyGraph: { b: ['a'] },
}),
variableTypes: { a: 'QUERY', b: 'QUERY', dyn1: 'DYNAMIC' },
dynamicVariableOrder: ['dyn1'],
});
variableFetchStore.update((d) => {
d.states.a = 'idle';
d.states.b = 'idle';
d.states.dyn1 = 'idle';
});
enqueueDescendantsOfVariable('a');
const snapshot = variableFetchStore.getSnapshot();
// b's parent (a) is idle → b starts loading
expect(snapshot.states.b).toBe('loading');
// dyn1 must wait because b is now loading (not settled)
expect(snapshot.states.dyn1).toBe('waiting');
});
});
});

View File

@@ -134,7 +134,7 @@ describe('dashboardVariablesStore', () => {
expect(dependencyData).not.toBeNull();
});
it('should report doAllVariablesHaveValuesSelected as true when all variables have selectedValue', () => {
it('should report doAllQueryVariablesHaveValuesSelected as true when all query variables have values', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
@@ -146,18 +146,46 @@ describe('dashboardVariablesStore', () => {
}),
region: createVariable({
name: 'region',
type: 'CUSTOM',
type: 'QUERY',
order: 1,
selectedValue: 'us-east',
}),
},
});
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
expect(doAllVariablesHaveValuesSelected).toBe(true);
const {
doAllQueryVariablesHaveValuesSelected,
} = getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
it('should report doAllVariablesHaveValuesSelected as false when some variables lack selectedValue', () => {
it('should report doAllQueryVariablesHaveValuesSelected as false when a query variable lacks a selectedValue', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue: 'prod',
}),
region: createVariable({
name: 'region',
type: 'QUERY',
order: 1,
selectedValue: undefined,
}),
},
});
const {
doAllQueryVariablesHaveValuesSelected,
} = getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(false);
});
it('should ignore non-QUERY variables when computing doAllQueryVariablesHaveValuesSelected', () => {
// env (QUERY) has a value; region (CUSTOM) and dyn1 (DYNAMIC) do not — they are ignored
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
@@ -173,62 +201,22 @@ describe('dashboardVariablesStore', () => {
order: 1,
selectedValue: undefined,
}),
},
});
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
expect(doAllVariablesHaveValuesSelected).toBe(false);
});
it('should treat DYNAMIC variable with allSelected=true and selectedValue=null as having a value', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 0,
selectedValue: null as any,
allSelected: true,
}),
env: createVariable({
name: 'env',
type: 'QUERY',
order: 1,
selectedValue: 'prod',
order: 2,
selectedValue: '',
}),
},
});
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
expect(doAllVariablesHaveValuesSelected).toBe(true);
const {
doAllQueryVariablesHaveValuesSelected,
} = getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
it('should treat DYNAMIC variable with allSelected=true and selectedValue=undefined as having a value', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 0,
selectedValue: undefined,
allSelected: true,
}),
env: createVariable({
name: 'env',
type: 'QUERY',
order: 1,
selectedValue: 'prod',
}),
},
});
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
expect(doAllVariablesHaveValuesSelected).toBe(true);
});
it('should treat DYNAMIC variable with allSelected=true and empty string selectedValue as having a value', () => {
it('should return true for doAllQueryVariablesHaveValuesSelected when there are no query variables', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
@@ -237,61 +225,72 @@ describe('dashboardVariablesStore', () => {
type: 'DYNAMIC',
order: 0,
selectedValue: '',
allSelected: true,
}),
env: createVariable({
name: 'env',
type: 'QUERY',
order: 1,
selectedValue: 'prod',
}),
},
});
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
expect(doAllVariablesHaveValuesSelected).toBe(true);
const {
doAllQueryVariablesHaveValuesSelected,
} = getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
it('should treat DYNAMIC variable with allSelected=true and empty array selectedValue as having a value', () => {
// Any non-nil, non-empty-array selectedValue is treated as selected
it.each([
{ label: 'numeric 0', selectedValue: 0 as number },
{ label: 'boolean false', selectedValue: false as boolean },
// ideally not possible but till we have concrete schema, we should not block dynamic variables
{ label: 'empty string', selectedValue: '' },
{
label: 'non-empty array',
selectedValue: ['a', 'b'] as (string | number | boolean)[],
},
])('should return true when selectedValue is $label', ({ selectedValue }) => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 0,
selectedValue: [] as any,
allSelected: true,
}),
env: createVariable({
name: 'env',
type: 'QUERY',
order: 1,
selectedValue: 'prod',
}),
},
});
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
expect(doAllVariablesHaveValuesSelected).toBe(true);
});
it('should report false when a DYNAMIC variable has empty selectedValue and allSelected is not true', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
dyn1: createVariable({
name: 'dyn1',
type: 'DYNAMIC',
order: 0,
selectedValue: '',
allSelected: false,
selectedValue,
}),
},
});
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
expect(doAllVariablesHaveValuesSelected).toBe(false);
const {
doAllQueryVariablesHaveValuesSelected,
} = getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(true);
});
// null/undefined (tested above) and empty array are treated as not selected
it.each([
{
label: 'null',
selectedValue: null as IDashboardVariable['selectedValue'],
},
{ label: 'empty array', selectedValue: [] as (string | number | boolean)[] },
])(
'should return false when selectedValue is $label',
({ selectedValue }) => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
type: 'QUERY',
order: 0,
selectedValue,
}),
},
});
const {
doAllQueryVariablesHaveValuesSelected,
} = getVariableDependencyContext();
expect(doAllQueryVariablesHaveValuesSelected).toBe(false);
},
);
});
});

View File

@@ -1,4 +1,4 @@
import { isEmpty, isUndefined } from 'lodash-es';
import { isNil } from 'lodash-es';
import createStore from '../store';
import { VariableFetchContext } from '../variableFetchStore';
@@ -68,28 +68,28 @@ export function updateDashboardVariablesStore({
*/
export function getVariableDependencyContext(): VariableFetchContext {
const state = dashboardVariablesStore.getSnapshot();
// Dynamic variables should only wait on query variables having values,
// not on CUSTOM, TEXTBOX, or other types.
const doAllQueryVariablesHaveValuesSelected = Object.values(
state.variables,
).every((variable) => {
if (variable.type !== 'QUERY') {
return true;
}
// If every variable already has a selectedValue (e.g. persisted from
// localStorage/URL), dynamic variables can start in parallel.
// Otherwise they wait for query vars to settle first.
const doAllVariablesHaveValuesSelected = Object.values(state.variables).every(
(variable) => {
if (
variable.type === 'DYNAMIC' &&
(variable.selectedValue === null || isEmpty(variable.selectedValue)) &&
variable.allSelected === true
) {
return true;
}
if (isNil(variable.selectedValue)) {
return false;
}
return (
!isUndefined(variable.selectedValue) && !isEmpty(variable.selectedValue)
);
},
);
if (Array.isArray(variable.selectedValue)) {
return variable.selectedValue.length > 0;
}
return true;
});
return {
doAllVariablesHaveValuesSelected,
doAllQueryVariablesHaveValuesSelected,
variableTypes: state.variableTypes,
dynamicVariableOrder: state.dynamicVariableOrder,
dependencyData: state.dependencyData,

View File

@@ -36,7 +36,7 @@ export type VariableFetchContext = Pick<
IDashboardVariablesStoreState,
'variableTypes' | 'dynamicVariableOrder' | 'dependencyData'
> & {
doAllVariablesHaveValuesSelected: boolean;
doAllQueryVariablesHaveValuesSelected: boolean;
};
const initialState: IVariableFetchStoreState = {
@@ -88,7 +88,7 @@ export function initializeVariableFetchStore(variableNames: string[]): void {
*/
export function enqueueFetchOfAllVariables(): void {
const {
doAllVariablesHaveValuesSelected,
doAllQueryVariablesHaveValuesSelected,
dependencyData,
variableTypes,
dynamicVariableOrder,
@@ -116,7 +116,7 @@ export function enqueueFetchOfAllVariables(): void {
// otherwise wait for query variables to settle first
dynamicVariableOrder.forEach((name) => {
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
draft.states[name] = doAllVariablesHaveValuesSelected
draft.states[name] = doAllQueryVariablesHaveValuesSelected
? resolveFetchState(draft, name)
: 'waiting';
});
@@ -208,7 +208,11 @@ export function onVariableFetchFailure(name: string): void {
* ensures parents are set before children within a single update).
*/
export function enqueueDescendantsOfVariable(name: string): void {
const { dependencyData, variableTypes } = getVariableDependencyContext();
const {
dependencyData,
variableTypes,
dynamicVariableOrder,
} = getVariableDependencyContext();
if (!dependencyData) {
return;
}
@@ -230,5 +234,18 @@ export function enqueueDescendantsOfVariable(name: string): void {
? resolveFetchState(draft, desc)
: 'waiting';
});
// Dynamic variables implicitly depend on all query variable values.
// If all query variables are currently settled, start them immediately;
// otherwise they wait until query vars finish (unlocked via onVariableFetchComplete).
dynamicVariableOrder.forEach((dynName) => {
draft.cycleIds[dynName] = (draft.cycleIds[dynName] || 0) + 1;
draft.states[dynName] = areAllQueryVariablesSettled(
draft.states,
variableTypes,
)
? resolveFetchState(draft, dynName)
: 'waiting';
});
});
}

View File

@@ -947,6 +947,7 @@ export function QueryBuilderProvider({
searchParams?: Record<string, unknown>,
redirectingUrl?: typeof ROUTES[keyof typeof ROUTES],
shouldNotStringify?: boolean,
newTab?: boolean,
) => {
const queryType =
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
@@ -1013,7 +1014,7 @@ export function QueryBuilderProvider({
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
safeNavigate(generatedUrl);
safeNavigate(generatedUrl, { newTab });
},
[location.pathname, safeNavigate, urlQuery],
);

Some files were not shown because too many files have changed in this diff Show More