Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
8179d1a027 fix(alertmanager): resolve email SMTP settings from env 2026-07-26 18:00:15 +05:30
321 changed files with 78600 additions and 188507 deletions

View File

@@ -39,6 +39,8 @@ jobs:
matrix:
suite:
- alerts
- alertmanager
- alertmanagerrotation
- basepath
- callbackauthn
- cloudintegrations
@@ -54,7 +56,6 @@ jobs:
- querierscalar
- queriercommon
- rawexportdata
- promqlconformance
- querierauthz
- role
- rootuser

View File

@@ -8,8 +8,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlmigration"
"github.com/SigNoz/signoz/pkg/sqlmigrator"
@@ -147,7 +145,7 @@ func newSyncMigrator(ctx context.Context, config signoz.Config, sqlstoreProvider
return nil, err
}
sqlmigrations, err := sqlmigration.New(ctx, providerSettings, config.SQLMigration, signoz.NewSQLMigrationProviderFactories(sqlstore, sqlschema, telemetrystore, providerSettings, impldashboard.NewStore(sqlstore), impltag.NewModule(impltag.NewStore(sqlstore))))
sqlmigrations, err := sqlmigration.New(ctx, providerSettings, config.SQLMigration, signoz.NewSQLMigrationProviderFactories(sqlstore, sqlschema, telemetrystore, providerSettings))
if err != nil {
return nil, err
}

View File

@@ -7238,10 +7238,6 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedLogsLink:
type: string
relatedTracesLink:
type: string
ruleId:
type: string
ruleName:
@@ -9934,9 +9930,14 @@ paths:
get:
deprecated: false
description: This endpoint lists the services metadata for the specified cloud
provider, without any account context.
provider
operationId: ListServicesMetadata
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -9986,10 +9987,14 @@ paths:
/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}:
get:
deprecated: false
description: This endpoint gets a service definition for the specified cloud
provider, without any account context.
description: This endpoint gets a service for the specified cloud provider
operationId: GetService
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true

View File

@@ -551,12 +551,12 @@ func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID
continue
}
createdDashboard, err := module.dashboardModule.CreateV2(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboard.Definition)
createdDashboard, err := module.dashboardModule.Create(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboardtypes.PostableDashboard(dashboard.Definition))
if err != nil {
return err
}
integrationDashboard := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID.StringValue(), cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
integrationDashboard := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
if err := module.store.CreateIntegrationDashboard(ctx, integrationDashboard); err != nil {
return err
}

View File

@@ -89,6 +89,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
useDashboardV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseDashboardV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
Active: useDashboardV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),

View File

@@ -2,29 +2,6 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
// The wire shape these handlers can actually rely on. The generated
// RenderErrorResponseDTO marks code/message/url/errors as required, but the
// server omits any of them even on valid errors (e.g. a 400 with just a
// message), so a present `error` object is all the guard can guarantee.
type ErrorEnvelope = {
error: {
code?: string;
message?: string;
url?: string;
errors?: { message?: string }[];
};
};
function isErrorEnvelope(data: unknown): data is ErrorEnvelope {
return (
typeof data === 'object' &&
data !== null &&
'error' in data &&
typeof (data as ErrorEnvelope).error === 'object' &&
(data as ErrorEnvelope).error !== null
);
}
// @deprecated Use convertToApiError instead
export function ErrorResponseHandlerForGeneratedAPIs(
error: AxiosError<RenderErrorResponseDTO>,
@@ -33,29 +10,15 @@ export function ErrorResponseHandlerForGeneratedAPIs(
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (response) {
// The body isn't guaranteed to be an error envelope — e.g. a gateway 5xx
// with an HTML/empty body during a deploy. Verify the shape before reading
// it; otherwise synthesize a consistent error from the status.
const data: unknown = response.data;
if (isErrorEnvelope(data)) {
const { code, message, url, errors } = data.error;
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: code ?? '',
message: message ?? '',
url: url ?? '',
errors: (errors ?? []).map((e) => ({ message: e.message ?? '' })),
},
});
}
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
code: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url ?? '',
errors: (response.data.error.errors ?? []).map((e) => ({
message: e.message ?? '',
})),
},
});
}
@@ -99,7 +62,9 @@ export function convertToApiError(
return new APIError({
httpStatusCode: response?.status || error.status || 500,
error: {
code: errorData?.code || 'UPSTREAM_UNAVAILABLE',
code:
errorData?.code ||
String(response?.status || error.code || 'unknown_error'),
message:
errorData?.message ||
response?.statusText ||

View File

@@ -2,38 +2,19 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
function isErrorV2Resp(data: unknown): data is ErrorV2Resp {
return (
typeof data === 'object' &&
data !== null &&
'error' in data &&
typeof (data as ErrorV2Resp).error?.code === 'string'
);
}
// reference - https://axios-http.com/docs/handling_errors
export function ErrorResponseHandlerV2(error: AxiosError<ErrorV2Resp>): never {
const { response, request } = error;
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (response) {
// response.data isn't guaranteed to be a V2 envelope (e.g. a gateway 5xx
// with an HTML/empty body during a deploy), so verify the shape first.
const data: unknown = response.data;
if (isErrorV2Resp(data)) {
const { code, message, url, errors } = data.error;
throw new APIError({
httpStatusCode: response.status || 500,
error: { code, message, url, errors },
});
}
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
code: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url,
errors: response.data.error.errors,
},
});
}

View File

@@ -1,126 +0,0 @@
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
function asAxiosError(partial: Partial<AxiosError>): AxiosError<ErrorV2Resp> {
return partial as AxiosError<ErrorV2Resp>;
}
// ErrorResponseHandlerV2 always throws — capture the APIError so assertions stay
// unconditional.
function runHandler(error: AxiosError<ErrorV2Resp>): APIError {
try {
ErrorResponseHandlerV2(error);
} catch (thrown) {
return thrown as APIError;
}
throw new Error('expected ErrorResponseHandlerV2 to throw');
}
type ExpectedError = {
httpStatusCode: number;
code: string;
message: string;
errors: { message: string }[];
};
// One row per response shape the handler must normalize. New shapes (with
// different bodies) can be added here without a new test block.
const cases: {
name: string;
error: AxiosError<ErrorV2Resp>;
expected: ExpectedError;
}[] = [
{
name: 'well-formed V2 error envelope',
error: asAxiosError({
message: 'Request failed with status code 400',
response: {
status: 400,
data: {
error: {
code: 'bad_request',
message: 'Invalid dashboard payload',
url: 'https://signoz.io/docs',
errors: [{ message: 'name is required' }, { message: 'name too long' }],
},
},
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 400,
code: 'bad_request',
message: 'Invalid dashboard payload',
errors: [{ message: 'name is required' }, { message: 'name too long' }],
},
},
{
// Regression: during a deployment the gateway returns a 5xx with a
// non-envelope body. Reading response.data.error.code used to throw a
// TypeError from inside the handler itself. See engineering-pod#5760.
name: '5xx with a non-envelope HTML body',
error: asAxiosError({
message: 'Request failed with status code 503',
response: {
status: 503,
data: '<html><body>503 Service Temporarily Unavailable</body></html>',
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 503,
code: 'UPSTREAM_UNAVAILABLE',
message: 'Request failed with status code 503',
errors: [],
},
},
{
name: '5xx with an empty body',
error: asAxiosError({
message: 'Request failed with status code 502',
response: {
status: 502,
data: undefined,
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 502,
code: 'UPSTREAM_UNAVAILABLE',
message: 'Request failed with status code 502',
errors: [],
},
},
{
name: 'no response received (network error)',
error: asAxiosError({
message: 'Network Error',
code: 'ERR_NETWORK',
name: 'AxiosError',
request: {},
}),
expected: {
httpStatusCode: 500,
code: 'ERR_NETWORK',
message: 'Network Error',
errors: [],
},
},
];
describe('ErrorResponseHandlerV2', () => {
it.each(cases)(
'normalizes $name into a consistent APIError',
({ error, expected }) => {
const apiError = runHandler(error);
expect(apiError).toBeInstanceOf(APIError);
expect(apiError.getHttpStatusCode()).toBe(expected.httpStatusCode);
expect(apiError.getErrorCode()).toBe(expected.code);
expect(apiError.getErrorMessage()).toBe(expected.message);
// The sub-error messages feed several parts of the UI, so assert them.
expect(apiError.getErrorDetails().error.errors).toStrictEqual(
expected.errors,
);
},
);
});

View File

@@ -36,12 +36,14 @@ import type {
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccountServicesMetadata200,
ListAccountServicesMetadataPathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
ListServicesMetadataParams,
ListServicesMetadataPathParameters,
RenderErrorResponseDTO,
UpdateAccountPathParameters,
@@ -1160,24 +1162,30 @@ export const invalidateGetConnectionCredentials = async (
};
/**
* This endpoint lists the services metadata for the specified cloud provider, without any account context.
* This endpoint lists the services metadata for the specified cloud provider
* @summary List services metadata
*/
export const listServicesMetadata = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
method: 'GET',
params,
signal,
});
};
export const getListServicesMetadataQueryKey = ({
cloudProvider,
}: ListServicesMetadataPathParameters) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
export const getListServicesMetadataQueryKey = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services`,
...(params ? [params] : []),
] as const;
};
export const getListServicesMetadataQueryOptions = <
@@ -1185,6 +1193,7 @@ export const getListServicesMetadataQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1196,11 +1205,12 @@ export const getListServicesMetadataQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
queryOptions?.queryKey ??
getListServicesMetadataQueryKey({ cloudProvider }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
return {
queryKey,
@@ -1228,6 +1238,7 @@ export function useListServicesMetadata<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1238,6 +1249,7 @@ export function useListServicesMetadata<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
params,
options,
);
@@ -1254,10 +1266,11 @@ export function useListServicesMetadata<
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
options,
);
@@ -1265,26 +1278,29 @@ export const invalidateListServicesMetadata = async (
};
/**
* This endpoint gets a service definition for the specified cloud provider, without any account context.
* This endpoint gets a service for the specified cloud provider
* @summary Get service
*/
export const getService = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'GET',
params,
signal,
});
};
export const getGetServiceQueryKey = ({
cloudProvider,
serviceId,
}: GetServicePathParameters) => {
export const getGetServiceQueryKey = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
...(params ? [params] : []),
] as const;
};
@@ -1293,6 +1309,7 @@ export const getGetServiceQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1304,11 +1321,12 @@ export const getGetServiceQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
queryOptions?.queryKey ??
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, signal);
}) => getService({ cloudProvider, serviceId }, params, signal);
return {
queryKey,
@@ -1334,6 +1352,7 @@ export function useGetService<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1344,6 +1363,7 @@ export function useGetService<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
params,
options,
);
@@ -1360,10 +1380,11 @@ export function useGetService<
export const invalidateGetService = async (
queryClient: QueryClient,
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
options,
);

View File

@@ -8320,14 +8320,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedLogsLink?: string;
/**
* @type string
*/
relatedTracesLink?: string;
/**
* @type string
*/
@@ -10204,6 +10196,14 @@ export type GetConnectionCredentials200 = {
export type ListServicesMetadataPathParameters = {
cloudProvider: string;
};
export type ListServicesMetadataParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type ListServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
@@ -10216,6 +10216,14 @@ export type GetServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type GetServiceParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type GetService200 = {
data: CloudintegrationtypesServiceDTO;
/**

View File

@@ -380,88 +380,4 @@ describe('convertV5ResponseToLegacy', () => {
},
});
});
describe('raw logs body: extract lone `message` field', () => {
function makeRawResult(
rows: Array<{ timestamp: string; data: Record<string, any> }>,
type: 'raw' | 'trace' = 'raw',
): ReturnType<typeof convertV5ResponseToLegacy> {
const v5Data = {
type,
data: { results: [{ queryName: 'A', rows }] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
} as unknown as QueryRangeResponseV5;
const params = makeBaseParams(type as RequestType, [
{
type: 'builder_query',
spec: {
name: 'A',
signal: type === 'trace' ? 'traces' : 'logs',
stepInterval: 60,
disabled: false,
aggregations: [],
},
},
]);
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
makeBaseSuccess({ data: v5Data }, params);
return convertV5ResponseToLegacy(input, { A: 'A' }, false);
}
it('unwraps body when it is an object with only a message field', () => {
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: 'hello' } } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe('hello');
});
it('leaves body unchanged when the object has keys besides message', () => {
const body = { message: 'hello', level: 'INFO' };
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toStrictEqual(
body,
);
});
it('leaves a string body unchanged (use_json_body off)', () => {
const result = makeRawResult([
{
timestamp: '2026-07-21T00:00:00Z',
data: { body: '{"message":"hello"}' },
},
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
'{"message":"hello"}',
);
});
it('stringifies the nested object when message is an object', () => {
const nested = { a: 1, b: 2 };
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: nested } } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
JSON.stringify(nested),
);
});
it('does not add a body key to rows without a body (traces)', () => {
const result = makeRawResult(
[{ timestamp: '2026-07-21T00:00:00Z', data: { name: 'span-1' } }],
'trace',
);
const data = (result.payload.data.result[0].list?.[0] as any)?.data ?? {};
expect('body' in data).toBe(false);
});
});
});

View File

@@ -273,19 +273,6 @@ function convertScalarWithFormatForWeb(
});
}
function extractOnlyMessageBody(body: unknown): unknown {
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
if (isJsonBody) {
const keys = Object.keys(body);
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
if (hasOnlyMessageKey) {
const { message } = body as { message: unknown };
return typeof message === 'string' ? message : JSON.stringify(message);
}
}
return body;
}
/**
* Converts V5 RawData to legacy format
*/
@@ -298,22 +285,14 @@ function convertRawData(
queryName: rawData.queryName,
legend: legendMap[rawData.queryName] || rawData.queryName,
series: null,
list: rawData.rows?.map((row) => {
const data = {
list: rawData.rows?.map((row) => ({
timestamp: row.timestamp,
data: {
// Map raw data to ILog structure - spread row.data first to include all properties
...row.data,
date: row.timestamp,
} as any;
if ('body' in row.data) {
data.body = extractOnlyMessageBody(row.data.body);
}
return {
timestamp: row.timestamp,
data,
};
}),
} as any,
})),
nextCursor: rawData.nextCursor,
};
}

View File

@@ -18,10 +18,7 @@ import {
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
import { prepareQueryRangePayloadV5 } from './prepareQueryRangePayloadV5';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
@@ -902,36 +899,3 @@ describe('prepareQueryRangePayloadV5', () => {
expect(logSpec.filter).toStrictEqual({ expression: '' });
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.METRICS,
queryName: 'A',
aggregations: [{ metricName: 'm', spaceAggregation: 'p99' }],
having,
} as unknown as IBuilderQuery,
},
'time_series',
PANEL_TYPES.TIME_SERIES,
);
return envelope.spec as MetricBuilderQuery;
};
it.each([
['a legacy V4 array', []],
['a blank empty-object having', { expression: '' }],
['a whitespace-only having', { expression: ' ' }],
['a nullish having', undefined],
])('drops %s (serializes to undefined)', (_label, having) => {
expect(buildSpec(having).having).toBeUndefined();
});
it('preserves a real having expression', () => {
expect(buildSpec({ expression: 'count() > 5' }).having).toStrictEqual({
expression: 'count() > 5',
});
});
});

View File

@@ -134,21 +134,6 @@ function getFilter(queryData: IBuilderQuery): Filter {
};
}
/**
* Normalizes a builder query's `having` to the V5 shape, treating "no having filter" as absent.
* V4 stored it as an array; V5 expects `{ expression }`. An array (legacy), a nullish value, or a
* blank expression — the query builder seeds `{ expression: '' }` for an empty having — all mean
* "no having" and must serialize to `undefined`. Emitting an empty `{ expression: '' }` sends a
* no-op filter and, because a saved panel never carries one, reads an untouched panel as dirty.
*/
function normalizeHaving(having: unknown): Having | undefined {
if (having == null || Array.isArray(having)) {
return undefined;
}
const { expression } = having as Having;
return expression?.trim() ? (having as Having) : undefined;
}
function createBaseSpec(
queryData: IBuilderQuery,
requestType: RequestType,
@@ -196,7 +181,12 @@ function createBaseSpec(
)
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(queryData.having) || Array.isArray(queryData.having)
? undefined
: (queryData?.having as Having),
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {
@@ -424,7 +414,10 @@ function createTraceOperatorBaseSpec(
)
: undefined,
legend: isEmpty(legend) ? undefined : legend,
having: normalizeHaving(having),
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(having) || Array.isArray(having) ? undefined : (having as Having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(

View File

@@ -213,9 +213,7 @@ describe.each([
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.groupBy).toBeUndefined();
// An empty having ({ expression: '' }) is a no-op filter and serializes to
// undefined — same as the cleared groupBy above.
expect(query.spec.having).toBeUndefined();
expect(query.spec.having).toStrictEqual({ expression: '' });
});
});

View File

@@ -8,6 +8,7 @@ import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import {
FieldContext,
FieldDataType,
SignalType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
@@ -54,7 +55,7 @@ function OtherFields({
key: buildCompositeKey(attr.name, attr.fieldContext as string),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType,
fieldDataType: attr.fieldDataType as FieldDataType,
}),
);
const addedIds = new Set(

View File

@@ -20,7 +20,6 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
@@ -34,7 +33,6 @@ import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
filterOptionsBySearch,
findOptionLabelText,
handleScrollToBottom,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
@@ -1939,7 +1937,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
};
const tag = (
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': isActive,
@@ -1969,32 +1967,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
)}
</div>
);
// `label` arrives already cut to maxTagTextLength, so the reveal reads the
// option's own text (falling back to the raw value for freeform tags).
return (
<TooltipSimple
side="top"
delayDuration={300}
title={findOptionLabelText(options, value)}
>
{tag}
</TooltipSimple>
);
}
// Fallback for safety, should not be reached
return <div />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
isAllSelected,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
options,
],
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
);
// Simple onClear handler to prevent clearing ALL
@@ -2013,58 +1992,51 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== Component Rendering =====
return (
// Self-provided so the per-tag tooltips work wherever this select is rendered,
// without every consumer having to sit under an app-level provider.
<TooltipProvider>
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx(
'custom-multiselect-dropdown-container',
popupClassName,
)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
</TooltipProvider>
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
);
};

View File

@@ -1,66 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CustomMultiSelect from '../CustomMultiSelect';
const OPTIONS = [
{ label: 'checkout-service-prod', value: 'checkout-service-prod' },
{ label: 'payments-service-prod', value: 'payments-service-prod' },
{ label: 'cart-service-prod', value: 'cart-service-prod' },
];
const SELECTED = ['checkout-service-prod', 'payments-service-prod'];
function renderSelect(): void {
render(
<TooltipProvider>
<CustomMultiSelect
options={OPTIONS}
value={SELECTED}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
/>
</TooltipProvider>,
);
}
/** Hovers an element and lets the tooltip's open delay elapse. */
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('CustomMultiSelect tag tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("reveals a tag's untruncated value on hover", async () => {
renderSelect();
await hover(screen.getByText('checkout-s...'));
expect(screen.getByRole('tooltip')).toHaveTextContent(
'checkout-service-prod',
);
});
// The `+N` placeholder stays the caller's to render — several callers already wrap
// it in a tooltip of their own, and a second one would stack on top.
it('leaves the +N overflow placeholder untouched', async () => {
renderSelect();
await hover(screen.getByText('+1'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -109,21 +109,6 @@ export const prioritizeOrAddOptionForMultiSelect = (
return [...flatOutSelectedOptions, ...filteredOptions];
};
export const findOptionLabelText = (
options: OptionData[],
value: string,
): string => {
const match = options
.flatMap((option) =>
'options' in option && Array.isArray(option.options)
? option.options
: [option],
)
.find((option) => option.value === value);
return typeof match?.label === 'string' ? match.label : value;
};
/**
* Filters options based on search text
*/

View File

@@ -25,12 +25,12 @@ import CodeMirror, {
} from '@uiw/react-codemirror';
import { Button, Popover, Tooltip } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { QueryBuilderKeys } from 'constants/queryBuilder';
import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Info, TriangleAlert } from '@signozhq/icons';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { FieldDataType } from 'types/api/v5/queryRange';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
@@ -323,16 +323,16 @@ function QueryAggregationSelect({
TracesAggregatorOperator.RATE,
];
const fieldDataType: FieldDataType | undefined =
const fieldDataType =
functionContextForFetch &&
operatorsWithoutDataType.includes(functionContextForFetch)
? undefined
: 'number';
: QUERY_BUILDER_KEY_TYPES.NUMBER;
return getKeySuggestions({
signal: queryData.dataSource,
searchText: '',
fieldDataType,
fieldDataType: fieldDataType as QUERY_BUILDER_KEY_TYPES,
});
},
{

View File

@@ -92,76 +92,52 @@
color: var(--l3-foreground);
}
.only-btn,
.only-btn {
cursor: not-allowed;
color: var(--l3-foreground);
}
.toggle-btn {
cursor: not-allowed;
color: var(--l3-foreground);
}
}
// Stack "Only"/"All" and "Toggle" in a single cell so revealing
// one swaps in place instead of shifting the row layout.
.value-actions {
display: grid;
align-items: center;
justify-items: end;
> * {
grid-area: 1 / 1;
}
.only-btn {
display: none;
}
.only-btn,
.toggle-btn {
display: none;
}
.toggle-btn:hover {
background-color: unset;
}
.only-btn:hover {
background-color: unset;
}
}
.checkbox-value-section:hover {
.toggle-btn {
display: none;
}
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
opacity: 0;
transform: translateX(4px);
// `display` is animated as a discrete property so the button
// stays mounted through its fade-out on exit.
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
&:hover {
background-color: unset;
}
}
}
// Hovering the label/value area reveals the "Only"/"All" action.
.checkbox-value-section:hover .only-btn {
display: flex;
opacity: 1;
transform: translateX(0);
// Entry animation start state (fires on the display switch).
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
// Hovering the checkbox reveals the "Toggle" action.
.check-box:hover ~ .checkbox-value-section .toggle-btn {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
}
@media (prefers-reduced-motion: reduce) {
.only-btn,
.value:hover {
.toggle-btn {
transition: none;
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
}
}

View File

@@ -53,14 +53,12 @@ function CheckboxValueRow({
</Typography.Text>
</TooltipSimple>
)}
<div className="value-actions">
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
);

View File

@@ -1,20 +0,0 @@
@use '../../styles/scrollbar' as *;
// Padding for the tooltip body that hosts a scroll area: the right side is given
// up so the scrollbar can sit flush against the tooltip's edge instead of floating
// inset from it. `.scrollArea` puts that spacing back between the text and the bar.
.tooltipContent {
--tooltip-padding: var(--spacing-2) 0 var(--spacing-2) var(--spacing-4);
}
// How tall a hover reveal grows before its content starts scrolling.
.scrollArea {
max-height: 480px;
overflow-y: auto;
// Keep the page behind the tooltip still once the list hits its end.
overscroll-behavior: contain;
// Gap between the content and the scrollbar (or the tooltip edge when short).
padding-right: var(--spacing-4);
@include custom-scrollbar;
}

View File

@@ -1,25 +0,0 @@
import type { ReactNode } from 'react';
import styles from './TooltipScrollArea.module.scss';
interface TooltipScrollAreaProps {
children: ReactNode;
}
/**
* Pass as the hosting tooltip's content class (`tooltipContentProps`) so its
* padding makes room for the scroll area's own edge handling.
*/
export const TOOLTIP_SCROLL_CONTENT_CLASS = styles.tooltipContent;
/**
* Scroll container for hover reveals that list an unbounded number of items (tag
* chips, variable values): caps the tooltip's height and scrolls past it. Plain CSS
* overflow with a pinned-visible thin scrollbar — a tooltip is transient, so an
* auto-hiding scrollbar would leave no hint that there is more below.
*/
function TooltipScrollArea({ children }: TooltipScrollAreaProps): JSX.Element {
return <div className={styles.scrollArea}>{children}</div>;
}
export default TooltipScrollArea;

View File

@@ -1,11 +1,6 @@
.warning-popover-overlay {
--antd-arrow-background-color: var(--l2-background);
}
.warning-content {
display: flex;
flex-direction: column;
background-color: var(--l2-background);
// === SECTION: Summary (Top)
&__summary-section {
@@ -15,25 +10,16 @@
&__summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
padding: 16px;
}
&__summary-left {
display: flex;
align-items: flex-start;
align-items: baseline;
gap: 8px;
}
&__icon-wrapper {
display: flex;
align-items: center;
flex-shrink: 0;
height: var(--spacing-12);
}
&__summary-text {
display: flex;
flex-direction: column;

View File

@@ -2,7 +2,6 @@ import { ReactNode, useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Popover, PopoverProps } from 'antd';
import ErrorIcon from 'assets/Error';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { BookOpenText, ChevronsDown, TriangleAlert } from '@signozhq/icons';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
@@ -31,18 +30,16 @@ export function WarningContent({ warning }: WarningContentProps): JSX.Element {
{/* Summary Header */}
<section className="warning-content__summary-section">
<header className="warning-content__summary">
{(warningCode || warningMessage) && (
<div className="warning-content__summary-left">
<div className="warning-content__icon-wrapper">
<ErrorIcon />
</div>
<div className="warning-content__summary-text">
<h2 className="warning-content__warning-code">{warningCode}</h2>
<p className="warning-content__warning-message">{warningMessage}</p>
</div>
<div className="warning-content__summary-left">
<div className="warning-content__icon-wrapper">
<ErrorIcon />
</div>
)}
<div className="warning-content__summary-text">
<h2 className="warning-content__warning-code">{warningCode}</h2>
<p className="warning-content__warning-message">{warningMessage}</p>
</div>
</div>
{warningUrl && (
<div className="warning-content__summary-right">
@@ -157,10 +154,6 @@ function WarningPopover({
overlayInnerStyle={{ padding: 0 }}
autoAdjustOverflow
{...popoverProps}
overlayClassName={cx(
'warning-popover-overlay',
popoverProps.overlayClassName,
)}
>
{children || (
<TriangleAlert

View File

@@ -10,6 +10,7 @@ export enum FeatureKeys {
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',

View File

@@ -167,56 +167,6 @@ describe('getAutoContexts', () => {
]);
});
it('returns panel edit context on the V2 panel editor', () => {
const dashboardId = 'dash-123';
const panelId = 'panel-abc';
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
dashboardId,
).replace(':panelId', panelId);
const contexts = getAutoContexts(pathname, '');
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardId,
metadata: {
page: 'panel_edit',
widgetId: panelId,
},
},
]);
});
it('returns new panel context on the unsaved new-panel editor', () => {
const dashboardId = 'dash-123';
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
dashboardId,
).replace(':panelId', 'new');
const startTime = '1700000000000';
const endTime = '1700003600000';
const contexts = getAutoContexts(
pathname,
`?panelKind=TimeSeries&${QueryParams.startTime}=${startTime}&${QueryParams.endTime}=${endTime}`,
);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardId,
metadata: {
page: 'panel_create',
timeRange: { start: Number(startTime), end: Number(endTime) },
},
},
]);
});
it('returns empty array on alert overview without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, '');

View File

@@ -17,28 +17,6 @@ describe('resolvePageType', () => {
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.dashboard_detail);
});
it('returns panel_edit on the V2 panel editor', () => {
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
'dash-123',
).replace(':panelId', 'panel-abc');
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.panel_edit);
});
// `panel_create` has no PageTypeDTO counterpart yet, so it reports
// `panel_edit` rather than degrading to `other`.
it('returns panel_edit on the unsaved new-panel editor', () => {
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
'dash-123',
).replace(':panelId', 'new');
expect(resolvePageType(pathname, '?panelKind=TimeSeries')).toBe(
PageTypeDTO.panel_edit,
);
});
it('returns alerts_triggered on alert history without ruleId', () => {
expect(resolvePageType(ROUTES.ALERT_HISTORY, '')).toBe(
PageTypeDTO.alerts_triggered,

View File

@@ -16,22 +16,17 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import getSessionStorage from 'api/browser/sessionstorage/get';
import setSessionStorage from 'api/browser/sessionstorage/set';
import {
getListDashboardsForUserV2QueryKey,
useListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import {
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRules200,
} from 'api/generated/services/sigNoz.schemas';
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useQueryService } from 'hooks/useQueryService';
import type { SuccessResponseV2 } from 'types/api';
import type { Dashboard } from 'types/api/dashboard/getAll';
// eslint-disable-next-line
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -105,8 +100,6 @@ function autoContextLabel(ctx: MessageContext): string {
return 'Current dashboard';
case 'panel_edit':
return 'Editing panel';
case 'panel_create':
return 'New panel';
case 'panel_fullscreen':
return 'Panel (fullscreen)';
case 'dashboard_list':
@@ -171,18 +164,6 @@ const TEXTAREA_MAX_HEIGHT_PX = 200;
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
/**
* The picker filters client-side, so it pulls one large page instead of
* paginating. Shared with `getQueryData` below — the params are part of the
* generated query key, so both sides must use the same object.
*/
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
function dashboardTitle(
dashboard: DashboardtypesListedDashboardForUserV2DTO,
): string {
return dashboard.spec.display?.name || dashboard.name || 'Untitled';
}
interface SelectedContextItem {
category: ContextCategory;
@@ -735,11 +716,9 @@ export default function ChatInput({
data: dashboardsResponse,
isLoading: isDashboardsLoading,
isError: isDashboardsError,
} = useListDashboardsForUserV2(DASHBOARD_LIST_PARAMS, {
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
},
} = useGetAllDashboard({
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
});
const {
@@ -786,12 +765,12 @@ export default function ChatInput({
return ctx.resourceId;
}
if (ctx.type === 'dashboard' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListDashboardsForUserV2200>(
getListDashboardsForUserV2QueryKey(DASHBOARD_LIST_PARAMS),
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
);
const dash = cached?.data?.dashboards?.find((d) => d.id === ctx.resourceId);
if (dash) {
return dashboardTitle(dash);
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
if (dash?.data.title) {
return dash.data.title;
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
@@ -821,9 +800,9 @@ export default function ChatInput({
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
{
Dashboards:
dashboardsResponse?.data?.dashboards?.map((dashboard) => ({
dashboardsResponse?.data?.map((dashboard) => ({
id: dashboard.id,
value: dashboardTitle(dashboard),
value: dashboard.data.title ?? 'Untitled',
})) ?? [],
Alerts:
alertsResponse?.data

View File

@@ -2,13 +2,12 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The prefill flow only depends on the context-picker data hooks resolving to
// empty lists (so the empty state renders) — mock them to skip real fetches.
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
useGetAllDashboard: (): unknown => ({
data: [],
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('api/generated/services/rules', () => ({

View File

@@ -2,7 +2,6 @@ import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AlertListTabs } from 'pages/AlertList/types';
import { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import { matchPath } from 'react-router-dom';
/**
@@ -31,23 +30,22 @@ export function getAutoContexts(
// ── Dashboards ────────────────────────────────────────────────────────────
// Panel editor (V2). `panel/new` has no widget id yet and the schema requires
// a non-empty `panel_edit.widgetId`, so it reports `panel_create` instead.
const panelEditorMatch = matchPath<{ dashboardId: string; panelId: string }>(
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
pathname,
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
);
if (panelEditorMatch) {
const { dashboardId, panelId } = panelEditorMatch.params;
const isNewPanel = panelId === NEW_PANEL_ID;
if (widgetMatch) {
return [
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardId,
metadata: isNewPanel
? { page: 'panel_create', ...sharedMetadata }
: { page: 'panel_edit', widgetId: panelId, ...sharedMetadata },
resourceId: widgetMatch.params.dashboardId,
metadata: {
page: 'panel_edit',
widgetId: widgetMatch.params.widgetId,
...sharedMetadata,
},
},
];
}

View File

@@ -9,8 +9,6 @@ const PAGE_METADATA_TO_DTO: Record<string, PageTypeDTO> = {
dashboard_detail: PageTypeDTO.dashboard_detail,
dashboard_list: PageTypeDTO.dashboard_list,
panel_edit: PageTypeDTO.panel_edit,
// There is no panel_create, so sending panel_edit temporarily
panel_create: PageTypeDTO.panel_edit,
panel_fullscreen: PageTypeDTO.panel_fullscreen,
logs_explorer: PageTypeDTO.logs_explorer,
trace_detail: PageTypeDTO.trace_detail,

View File

@@ -56,19 +56,14 @@
margin-top: 4px;
}
// width/height are set inline from the computed chart dimensions; border-box
// keeps the padding inside that height and overflow:hidden clips the
// virtualized grid to the rectangle.
// Wraps the shared chart Legend. Its width/height are set inline from the
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
// box (right column / bottom rows) the uPlot charts use.
.pieChartLegend {
flex: 0 0 auto;
box-sizing: border-box;
min-height: 0;
min-width: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
}
.pieChartLegendRight {
padding-top: 8px;
}

View File

@@ -3,7 +3,6 @@ import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie as VisxPie } from '@visx/shape';
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
@@ -211,9 +210,7 @@ export default function Pie({
)}
</div>
<div
className={cx(styles.pieChartLegend, {
[styles.pieChartLegendRight]: isRightLegend,
})}
className={styles.pieChartLegend}
style={{
width: legendWidth,
height: legendHeight,

View File

@@ -4,13 +4,8 @@ import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableDa
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { rest, server } from 'mocks-server/server';
import {
defaultFeatureFlags,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
@@ -46,16 +41,12 @@ const mockUseHistory = jest.mocked(useHistory);
// Mock data
const TEST_QUERY_ID = 'test-query-id';
const TEST_DASHBOARD_ID = 'test-dashboard-id';
const TEST_DASHBOARD_TITLE = 'Test Dashboard';
const TEST_DASHBOARD_DESCRIPTION = 'Test Description';
const TEST_TIMESTAMP = '2023-01-01T00:00:00Z';
const TEST_DASHBOARD_TITLE_2 = 'Test Dashboard for Export';
const NEW_DASHBOARD_ID = 'new-dashboard-id';
// The export dialog talks to the generated Perses-spec dashboard endpoints.
const V2_LIST_ENDPOINT = '*/api/v2/users/me/dashboards';
const V2_CREATE_ENDPOINT = '*/api/v2/dashboards';
// "Create new dashboard" names the dashboard from the `new_dashboard_title` i18n
// key; the test-utils react-i18next mock returns the key verbatim.
const NEW_DASHBOARD_TITLE = 'new_dashboard_title';
const DASHBOARDS_API_ENDPOINT = '*/api/v1/dashboards';
// Use the existing mock query from the codebase
const mockQuery: Query = {
@@ -63,44 +54,23 @@ const mockQuery: Query = {
id: TEST_QUERY_ID, // Override with our test ID
} as Query;
const createMockDashboard = (id: string = TEST_DASHBOARD_ID): Dashboard => ({
id,
data: {
title: TEST_DASHBOARD_TITLE,
description: TEST_DASHBOARD_DESCRIPTION,
tags: [],
layout: [],
variables: {},
},
createdAt: TEST_TIMESTAMP,
updatedAt: TEST_TIMESTAMP,
createdBy: 'test-user',
updatedBy: 'test-user',
});
const ADD_TO_DASHBOARD_BUTTON_NAME = /add to dashboard/i;
interface PickerDashboard {
id: string;
title: string;
}
/** Register the "list dashboards" endpoint the picker reads. */
function mockDashboardList(dashboards: PickerDashboard[]): void {
server.use(
rest.get(V2_LIST_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
dashboards: dashboards.map(({ id, title }) => ({
id,
name: title,
spec: { display: { name: title } },
})),
reservedKeywords: [],
},
}),
),
),
);
}
/** Register the "create dashboard" endpoint the "New dashboard" button hits. */
function mockCreateDashboard(id: string): void {
server.use(
rest.post(V2_CREATE_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: { id } })),
),
);
}
// Helper function to render component with props
const renderExplorerOptionWrapper = (
overrides = {},
@@ -134,8 +104,6 @@ const renderExplorerOptionWrapper = (
splitedQueries={props.splitedQueries}
signalSource={props.signalSource}
/>,
undefined,
{ appContextOverrides: { featureFlags: defaultFeatureFlags } },
);
};
@@ -189,9 +157,17 @@ describe('ExplorerOptionWrapper', () => {
) => void
>;
mockCreateDashboard(NEW_DASHBOARD_ID);
// Mock the dashboard creation API
const mockNewDashboard = createMockDashboard(NEW_DASHBOARD_ID);
server.use(
rest.post(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNewDashboard })),
),
);
renderExplorerOptionWrapper({ onExport: testOnExport });
renderExplorerOptionWrapper({
onExport: testOnExport,
});
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
@@ -211,7 +187,7 @@ describe('ExplorerOptionWrapper', () => {
// Wait for the API call to complete and onExport to be called
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: NEW_DASHBOARD_ID, title: NEW_DASHBOARD_TITLE },
{ id: NEW_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE },
true,
);
});
@@ -228,13 +204,21 @@ describe('ExplorerOptionWrapper', () => {
>;
// Mock existing dashboards with unique titles
const dashboards: PickerDashboard[] = [
{ id: 'dashboard-1', title: 'Dashboard 1' },
{ id: 'dashboard-2', title: 'Dashboard 2' },
];
mockDashboardList(dashboards);
const mockDashboard1 = createMockDashboard('dashboard-1');
mockDashboard1.data.title = 'Dashboard 1';
const mockDashboard2 = createMockDashboard('dashboard-2');
mockDashboard2.data.title = 'Dashboard 2';
const mockDashboards = [mockDashboard1, mockDashboard2];
renderExplorerOptionWrapper({ onExport: testOnExport });
server.use(
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockDashboards })),
),
);
renderExplorerOptionWrapper({
onExport: testOnExport,
});
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
@@ -247,15 +231,10 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select to render AND finish loading. antd
// disables the Select while the list request is in flight, and a click
// on a disabled combobox is a no-op — so sync on the ready (enabled)
// state before clicking.
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
const combobox = modal.querySelector('[role="combobox"]');
expect(combobox).toBeTruthy();
expect(combobox).not.toBeDisabled();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
const dashboardSelect = modal.querySelector(
@@ -266,11 +245,11 @@ describe('ExplorerOptionWrapper', () => {
// Wait for the dropdown options to appear and select the first dashboard
await waitFor(() => {
expect(screen.getByText(dashboards[0].title)).toBeInTheDocument();
expect(screen.getByText(mockDashboard1.data.title)).toBeInTheDocument();
});
// Click on the first dashboard option
const dashboardOption = screen.getByText(dashboards[0].title);
const dashboardOption = screen.getByText(mockDashboard1.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
@@ -285,7 +264,7 @@ describe('ExplorerOptionWrapper', () => {
// Wait for onExport to be called with the selected dashboard
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: dashboards[0].id, title: dashboards[0].title },
{ id: 'dashboard-1', title: 'Dashboard 1' },
false,
);
});
@@ -326,12 +305,18 @@ describe('ExplorerOptionWrapper', () => {
};
// Mock existing dashboards
const dashboards: PickerDashboard[] = [
{ id: TEST_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE_2 },
];
mockDashboardList(dashboards);
const mockDashboard = createMockDashboard('test-dashboard-id');
mockDashboard.data.title = TEST_DASHBOARD_TITLE_2;
renderExplorerOptionWrapper({ onExport: handleExport });
server.use(
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [mockDashboard] })),
),
);
renderExplorerOptionWrapper({
onExport: handleExport,
});
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
@@ -344,12 +329,10 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select to render AND finish loading (see note above).
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
const combobox = modal.querySelector('[role="combobox"]');
expect(combobox).toBeTruthy();
expect(combobox).not.toBeDisabled();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
const dashboardSelect = modal.querySelector(
@@ -360,11 +343,11 @@ describe('ExplorerOptionWrapper', () => {
// Wait for the dropdown options to appear and select the dashboard
await waitFor(() => {
expect(screen.getByText(dashboards[0].title)).toBeInTheDocument();
expect(screen.getByText(mockDashboard.data.title)).toBeInTheDocument();
});
// Click on the dashboard option
const dashboardOption = screen.getByText(dashboards[0].title);
const dashboardOption = screen.getByText(mockDashboard.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
@@ -380,7 +363,7 @@ describe('ExplorerOptionWrapper', () => {
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${TEST_DASHBOARD_ID}/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
`/dashboard/test-dashboard-id/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
JSON.stringify(query),
)}`,
);
@@ -389,23 +372,23 @@ describe('ExplorerOptionWrapper', () => {
// Assert that useUpdateDashboard was NOT called (as per PR #8029)
expect(mockMutate).not.toHaveBeenCalled();
});
});
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>;
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>;
renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport });
renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport });
// The "Add to Dashboard" button should be disabled
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
// The "Add to Dashboard" button should be disabled
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
});
expect(addToDashboardButton).toBeDisabled();
});
expect(addToDashboardButton).toBeDisabled();
});
});

View File

@@ -9,6 +9,8 @@ import {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
@@ -20,7 +22,7 @@ import dialsUrl from '@/assets/Icons/dials.svg';
import { getItemIcon } from '../constants';
// The five most-recent dashboards from the V2 list API.
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
interface RecentDashboard {
id: string;
title: string;
@@ -36,27 +38,52 @@ export default function Dashboards({
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const isDashboardV2 = useIsDashboardV2();
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
// selects; the inactive one stays disabled so it never fires.
const {
data: v1List,
isLoading: v1Loading,
isError: v1Error,
} = useGetAllDashboard({ enabled: !isDashboardV2 });
const {
data: dashboardsList,
isLoading: isDashboardListLoading,
isError: isDashboardListError,
} = useListDashboardsForUserV2({
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
});
data: v2List,
isLoading: v2Loading,
isError: v2Error,
} = useListDashboardsForUserV2(
{
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
},
{ query: { enabled: isDashboardV2 } },
);
const sortedDashboards = useMemo<RecentDashboard[]>(
() =>
(dashboardsList?.data?.dashboards ?? []).map((d) => ({
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
if (isDashboardV2) {
return (v2List?.data?.dashboards ?? []).map((d) => ({
id: d.id,
title: d.spec?.display?.name ?? d.name,
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
})),
[dashboardsList],
);
}));
}
return [...(v1List?.data ?? [])]
.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 5)
.map((d) => ({
id: d.id,
title: d.data.title,
tags: d.data.tags ?? [],
}));
}, [isDashboardV2, v1List, v2List]);
useEffect(() => {
if (sortedDashboards.length > 0 && !loadingUserPreferences) {

View File

@@ -147,6 +147,7 @@ function ServiceDetails({
cloudProvider: type,
serviceId: serviceId || '',
},
undefined,
{
query: {
enabled: !!serviceId && !cloudAccountId,

View File

@@ -39,12 +39,9 @@ function ServicesList({
const {
data: providerServicesMetadata,
isLoading: isProviderServicesLoading,
} = useListServicesMetadata(
{ cloudProvider: type },
{
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
},
);
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
});
const servicesMetadata = hasConnectedAccounts
? accountServicesMetadata

View File

@@ -6,7 +6,7 @@ import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
useGetMetricAlerts,
useGetMetricDashboardsV2,
useGetMetricDashboards,
} from 'api/generated/services/metrics';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
@@ -38,7 +38,7 @@ function DashboardsAndAlertsPopover({
data: dashboardsData,
isLoading: isLoadingDashboards,
isError: isErrorDashboards,
} = useGetMetricDashboardsV2(
} = useGetMetricDashboards(
{
metricName,
},
@@ -55,8 +55,7 @@ function DashboardsAndAlertsPopover({
const dashboards = useMemo(() => {
const currentDashboards = dashboardsData?.data.dashboards ?? [];
// The API returns one entry per referencing panel, so a dashboard repeats
// once per panel that uses the metric.
// Remove duplicate dashboards
return currentDashboards.filter(
(dashboard, index, self) =>
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),

View File

@@ -23,7 +23,7 @@ const useGetMetricAlertsMock = jest.spyOn(
);
const useGetMetricDashboardsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricDashboardsV2',
'useGetMetricDashboards',
);
describe('DashboardsAndAlertsPopover', () => {
@@ -153,15 +153,11 @@ describe('DashboardsAndAlertsPopover', () => {
);
});
it('collapses multiple panels of the same dashboard into one entry', async () => {
it('renders unique dashboards even when there are duplicates', async () => {
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [
MOCK_DASHBOARD_1,
MOCK_DASHBOARD_2,
{ ...MOCK_DASHBOARD_1, panelId: '3', panelName: 'Panel 3' },
],
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1],
},
}),
);

View File

@@ -2,7 +2,7 @@ import * as metricsExplorerHooks from 'api/generated/services/metrics';
import {
GetMetricAlerts200,
GetMetricAttributes200,
GetMetricDashboardsV2200,
GetMetricDashboards200,
GetMetricHighlights200,
GetMetricMetadata200,
MetrictypesTemporalityDTO,
@@ -40,14 +40,14 @@ export function getMockMetricHighlightsData(
export const MOCK_DASHBOARD_1 = {
dashboardName: 'Dashboard 1',
dashboardId: '1',
panelId: '1',
panelName: 'Panel 1',
widgetId: '1',
widgetName: 'Widget 1',
};
export const MOCK_DASHBOARD_2 = {
dashboardName: 'Dashboard 2',
dashboardId: '2',
panelId: '2',
panelName: 'Panel 2',
widgetId: '2',
widgetName: 'Widget 2',
};
export const MOCK_ALERT_1 = {
alertName: 'Alert 1',
@@ -59,7 +59,7 @@ export const MOCK_ALERT_2 = {
};
export function getMockDashboardsData(
overrides?: Partial<GetMetricDashboardsV2200>,
overrides?: Partial<GetMetricDashboards200>,
{
isLoading = false,
isError = false,
@@ -67,7 +67,7 @@ export function getMockDashboardsData(
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboardsV2> {
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
return {
data: {
data: {
@@ -79,7 +79,7 @@ export function getMockDashboardsData(
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboardsV2>;
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
}
export function getMockAlertsData(

View File

@@ -113,7 +113,7 @@ const useOptionsMenu = ({
(suggestion) => ({
name: suggestion.name,
signal: suggestion.signal as SignalType,
fieldDataType: suggestion.fieldDataType,
fieldDataType: suggestion.fieldDataType as FieldDataType,
fieldContext: suggestion.fieldContext as FieldContext,
}),
);
@@ -192,7 +192,7 @@ const useOptionsMenu = ({
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType,
fieldDataType: e.fieldDataType as FieldDataType,
}));
}
if (dataSource === DataSource.TRACES) {

View File

@@ -4,11 +4,11 @@ import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import useDebounce from 'hooks/useDebounce';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
import { BreakoutOptionsProps } from './contextConfig';
import { BreakoutAttributeType } from './types';
@@ -80,7 +80,7 @@ function BreakoutOptions({
keyArray.forEach((keyData) => {
transformedOptions.push({
key: keyData.name,
dataType: fieldDataTypeToDataType(keyData.fieldDataType),
dataType: keyData.fieldDataType as QUERY_BUILDER_KEY_TYPES,
type: '',
});
});

View File

@@ -238,10 +238,6 @@ describe('TableDrilldown Breakout Functionality', () => {
expect(aggregateQueryData.groupBy).toHaveLength(1);
expect(aggregateQueryData.groupBy[0].key).toBe('deployment.environment');
// The picked field's type travels with it — dropping it leaves the breakout query
// untyped, so a drilldown on its result can't tell a number from a string.
expect(aggregateQueryData.groupBy[0].dataType).toBe('string');
// Verify that orderBy has been cleared (as per getBreakoutQuery logic)
expect(aggregateQueryData.orderBy).toStrictEqual([]);

View File

@@ -1,82 +0,0 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ClickedData } from 'periscope/components/ContextMenu';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getGroupContextMenuConfig } from '../contextConfig';
const GROUP_KEY = 'http.status_code';
const makeQuery = (dataType: string): Query =>
({
builder: {
queryData: [
{
queryName: 'A',
groupBy: [{ key: GROUP_KEY, dataType, type: 'attribute' }],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
}) as unknown as Query;
const clickedData: ClickedData = {
column: { dataIndex: GROUP_KEY },
record: { key: GROUP_KEY, timestamp: 0 },
};
const renderGroupMenu = (query: Query): void => {
const { items } = getGroupContextMenuConfig({
query,
clickedData,
panelType: PANEL_TYPES.TABLE,
onColumnClick: jest.fn(),
});
render(<div>{items}</div>);
};
describe('getGroupContextMenuConfig', () => {
// A `number` group-by used to throw: it isn't a key of QUERY_BUILDER_OPERATORS_BY_TYPES.
it('renders comparison operators for a `number` group-by column', () => {
renderGroupMenu(makeQuery('number'));
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
expect(screen.getByText('Is less than')).toBeInTheDocument();
});
it('renders only equality operators for a string group-by column', () => {
renderGroupMenu(makeQuery(DataTypes.String));
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(screen.getByText('Is not this')).toBeInTheDocument();
expect(
screen.queryByText('Is greater than or equal to'),
).not.toBeInTheDocument();
});
it('falls back to equality operators when the column has no known data type', () => {
renderGroupMenu(makeQuery(''));
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(
screen.queryByText('Is greater than or equal to'),
).not.toBeInTheDocument();
});
it('returns no items for a non-table panel', () => {
const config = getGroupContextMenuConfig({
query: makeQuery('number'),
clickedData,
panelType: PANEL_TYPES.TIME_SERIES,
onColumnClick: jest.fn(),
});
expect(config.items).toBeUndefined();
});
});

View File

@@ -1,11 +1,8 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
getOperatorsByDataType,
getQueryData,
getViewQuery,
isNumberDataType,
isValidQueryName,
} from '../drilldownUtils';
import { METRIC_TO_LOGS_TRACES_MAPPINGS } from '../metricsCorrelationUtils';
@@ -690,41 +687,4 @@ describe('drilldownUtils', () => {
expect(expr).toContain(`name = 'GET /api'`);
});
});
describe('getOperatorsByDataType', () => {
it('gives numeric operators for the V5 `number` data type', () => {
const operators = getOperatorsByDataType('number');
expect(operators).toContain('>=');
expect(operators).toContain('<');
expect(operators).not.toContain('LIKE');
});
it('gives numeric operators for the V3 int64 / float64 data types', () => {
expect(getOperatorsByDataType(DataTypes.Int64)).toContain('>=');
expect(getOperatorsByDataType(DataTypes.Float64)).toContain('>=');
});
it('falls back to the string operators for unmapped, empty and missing types', () => {
const stringOperators = getOperatorsByDataType(DataTypes.String);
expect(getOperatorsByDataType('[]string')).toStrictEqual(stringOperators);
expect(getOperatorsByDataType('')).toStrictEqual(stringOperators);
expect(getOperatorsByDataType(undefined)).toStrictEqual(stringOperators);
});
});
describe('isNumberDataType', () => {
it.each([DataTypes.Int64, DataTypes.Float64, 'number' as DataTypes])(
'treats %s as numeric',
(dataType) => {
expect(isNumberDataType(dataType)).toBe(true);
},
);
it.each([DataTypes.String, DataTypes.bool, DataTypes.EMPTY, undefined])(
'does not treat %s as numeric',
(dataType) => {
expect(isNumberDataType(dataType)).toBe(false);
},
);
});
});

View File

@@ -1,111 +0,0 @@
import { render, screen } from '@testing-library/react';
import {
DashboardtypesQueryDTO,
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5CompositeQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fromPerses,
toPerses,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { ClickedData } from 'periscope/components/ContextMenu';
import { getGroupContextMenuConfig } from '../contextConfig';
import {
addFilterToQuery,
getBaseMeta,
isNumberDataType,
} from '../drilldownUtils';
const GROUP_KEY = 'panja.pinger.status_code';
/**
* A saved V2 table panel grouped by a numeric attribute. The backend rewrites `float64` to
* `number` when it stores the panel, so `number` is what a reload actually carries.
*/
const savedPanelQueries = [
{
kind: 'signoz/scalar',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
aggregations: [{ metricName: 'probe_checks', spaceAggregation: 'sum' }],
groupBy: [
{
name: GROUP_KEY,
fieldDataType: 'number',
fieldContext: 'attribute',
},
],
filter: { expression: '' },
disabled: false,
},
},
],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
describe('drilldown on a numeric group-by column (V2 table panel)', () => {
const v1Query = fromPerses(savedPanelQueries, PANEL_TYPES.TABLE);
const groupByDataType = getBaseMeta(v1Query, GROUP_KEY)?.dataType;
it('sees the `number` type the panel was stored with', () => {
expect(groupByDataType).toBe('number');
});
it('offers the numeric operators instead of throwing', () => {
const clickedData: ClickedData = {
column: { dataIndex: GROUP_KEY },
record: { key: GROUP_KEY, timestamp: 0 },
};
const { items } = getGroupContextMenuConfig({
query: v1Query,
clickedData,
panelType: PANEL_TYPES.TABLE,
onColumnClick: jest.fn(),
});
render(<div>{items}</div>);
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
});
it('filters on an unquoted number', () => {
// What the filter hooks branch on before coercing the clicked value.
expect(isNumberDataType(groupByDataType)).toBe(true);
const refined = addFilterToQuery(v1Query, [
{ filterKey: GROUP_KEY, filterValue: 200, operator: '=' },
]);
expect(refined.builder.queryData[0].filter?.expression).toBe(
`${GROUP_KEY} = 200`,
);
});
it('leaves the stored query shape untouched on the way back out', () => {
const [envelope] = toPerses(v1Query, PANEL_TYPES.TABLE);
const composite = envelope.spec.plugin
.spec as Querybuildertypesv5CompositeQueryDTO;
const [query] = composite.queries ?? [];
// The generated envelope union doesn't discriminate `spec` by `type`.
const spec = query?.spec as Querybuildertypesv5BuilderQuerySpecDTO;
expect(spec.groupBy?.[0]).toMatchObject({
name: GROUP_KEY,
fieldDataType: 'number',
fieldContext: 'attribute',
});
});
});

View File

@@ -1,9 +1,12 @@
import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
PANEL_TYPES,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { getBaseMeta, getOperatorsByDataType } from './drilldownUtils';
import { getBaseMeta } from './drilldownUtils';
import { SUPPORTED_OPERATORS } from './menuOptions';
import { BreakoutAttributeType } from './types';
@@ -46,9 +49,15 @@ export function getGroupContextMenuConfig({
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
const filterKey = clickedData?.column?.dataIndex;
const filterDataType = getBaseMeta(query, filterKey as string)?.dataType;
const filterDataType =
getBaseMeta(query, filterKey as string)?.dataType || 'string';
const filterOperators = getOperatorsByDataType(filterDataType).filter(
const operators =
QUERY_BUILDER_OPERATORS_BY_TYPES[
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
];
const filterOperators = operators.filter(
(operator) => SUPPORTED_OPERATORS[operator],
);

View File

@@ -3,7 +3,6 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
import {
initialQueryBuilderFormValuesMap,
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { isApmMetric } from 'container/PanelWrapper/utils';
@@ -55,44 +54,13 @@ export const getRoute = (key: string): string => {
}
};
/**
* A group-by's `dataType` comes from the field metadata, which reports a numeric as `number`
* (V5, what a saved V2 panel carries) or as `int64` / `float64` (V3) — all three are numeric.
*/
const NUMERIC_DATA_TYPES: string[] = [
DataTypes.Int64,
DataTypes.Float64,
'number',
];
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
if (!dataType) {
return false;
}
return NUMERIC_DATA_TYPES.includes(dataType);
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
};
/**
* `QUERY_BUILDER_OPERATORS_BY_TYPES` is keyed by the V3 data types, so the `number` a saved V2
* panel carries misses it — and a raw lookup returns `undefined`, which throws on the caller's
* `.filter`. Resolve through this table and fall back to the string set.
*/
const OPERATOR_DATA_TYPES: Record<
string,
keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
> = {
[DataTypes.String]: 'string',
[DataTypes.Int64]: 'int64',
[DataTypes.Float64]: 'float64',
[DataTypes.bool]: 'bool',
number: 'float64',
};
export const getOperatorsByDataType = (dataType?: string): string[] =>
QUERY_BUILDER_OPERATORS_BY_TYPES[
OPERATOR_DATA_TYPES[dataType ?? ''] ?? 'string'
];
export interface FilterData {
filterKey: string;
filterValue: string | number;

View File

@@ -1,5 +1,6 @@
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
import cloneDeep from 'lodash-es/cloneDeep';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { addFilterToSelectedQuery, FilterData } from './drilldownUtils';
@@ -88,11 +89,7 @@ export const getBreakoutQuery = (
.filter((item: IBuilderQuery) => item.queryName === aggregateData.queryName)
.map((item: IBuilderQuery) => ({
...item,
// The picked field's type travels with it: dropped, the breakout query goes out
// untyped and a drilldown on its result can't tell a number from a string.
groupBy: [
{ key: groupBy.key, dataType: groupBy.dataType, type: groupBy.type },
],
groupBy: [{ key: groupBy.key, type: groupBy.type } as BaseAutocompleteData],
orderBy: [],
legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : '',
}));

View File

@@ -1,8 +1,6 @@
import { ReactNode } from 'react';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export type ContextMenuItem = ReactNode;
@@ -33,8 +31,7 @@ export interface AggregateContextMenuConfig {
export interface BreakoutAttributeType {
key: string;
/** The picked field's type, in the vocabulary the group-by it becomes is read with. */
dataType: DataTypes;
dataType: QUERY_BUILDER_KEY_TYPES;
type: string;
}

View File

@@ -2,9 +2,15 @@ import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useCreateExportDashboard } from '../useCreateExportDashboard';
jest.mock('hooks/useIsDashboardV2');
jest.mock('api/v1/dashboards/create');
jest.mock('api/generated/services/dashboard', () => ({
createDashboardV2: jest.fn(),
}));
@@ -14,6 +20,10 @@ jest.mock('providers/ErrorModalProvider', () => ({
}),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockCreateV1 = createDashboardV1 as jest.Mock;
const mockCreateV2 = createDashboardV2 as jest.Mock;
function wrapper({ children }: { children: ReactNode }): JSX.Element {
@@ -30,7 +40,35 @@ beforeEach(() => {
});
describe('useCreateExportDashboard', () => {
it('creates via the V2 Perses endpoint and normalizes the response', async () => {
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
mockUseIsDashboardV2.mockReturnValue(false);
const v1Dashboard = {
id: 'v1-new',
data: { title: TITLE },
} as unknown as Dashboard;
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith({ id: 'v1-new', title: TITLE }),
);
expect(mockCreateV1).toHaveBeenCalledWith({
title: TITLE,
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
expect(mockCreateV2).not.toHaveBeenCalled();
});
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
mockUseIsDashboardV2.mockReturnValue(true);
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
const onCreated = jest.fn();
@@ -50,5 +88,6 @@ describe('useCreateExportDashboard', () => {
spec: expect.objectContaining({ display: { name: TITLE } }),
}),
);
expect(mockCreateV1).not.toHaveBeenCalled();
});
});

View File

@@ -1,18 +1,44 @@
import { renderHook } from '@testing-library/react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useExportDashboards } from '../useExportDashboards';
jest.mock('hooks/useIsDashboardV2');
jest.mock('hooks/dashboard/useGetAllDashboard');
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: jest.fn(),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
const v1Refetch = jest.fn();
const v2Refetch = jest.fn();
const v1Dashboard = {
id: 'v1-1',
data: { title: 'V1 Dashboard' },
} as unknown as Dashboard;
const v1Other = {
id: 'v1-2',
data: { title: 'Other board' },
} as unknown as Dashboard;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAllDashboard.mockReturnValue({
data: { data: [v1Dashboard, v1Other] },
isLoading: false,
isFetching: false,
refetch: v1Refetch,
});
mockUseListV2.mockReturnValue({
data: {
data: {
@@ -32,21 +58,54 @@ beforeEach(() => {
});
describe('useExportDashboards', () => {
it('returns the V2 list normalized to the export shape', () => {
it('returns the V1 list and disables the V2 query when the flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
{ id: 'v1-2', title: 'Other board' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: { enabled: false, keepPreviousData: true },
}),
);
});
it('filters the V1 list in memory by title (case-insensitive)', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards('v1 dash'));
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
]);
});
it('returns the V2 list normalized to the export shape when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v2-1', title: 'V2 Dashboard' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: undefined }),
expect.objectContaining({
query: { keepPreviousData: true },
query: { enabled: true, keepPreviousData: true },
}),
);
});
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
mockUseIsDashboardV2.mockReturnValue(true);
renderHook(() => useExportDashboards('payments'));
expect(mockUseListV2).toHaveBeenCalledWith(
@@ -55,10 +114,12 @@ describe('useExportDashboards', () => {
);
});
it('refetches the V2 source', () => {
it('refetches the active source', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
result.current.refetch();
expect(v2Refetch).toHaveBeenCalledTimes(1);
expect(v1Refetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,15 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
jest.mock('hooks/useIsDashboardV2');
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const params = {
dashboardId: 'dash-1',
@@ -13,7 +19,21 @@ const params = {
};
describe('useGetExportToDashboardLink', () => {
it('builds a V2 panel/new link (ignoring widgetId)', () => {
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link?.startsWith('/dashboard/dash-1/new?')).toBe(true);
expect(link).toContain('graphType=');
expect(link).toContain('widgetId=w1');
expect(link).toContain('compositeQuery=');
});
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);

View File

@@ -1,6 +1,9 @@
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -17,13 +20,14 @@ interface UseCreateExportDashboardResult {
}
/**
* "Create a new dashboard to export into". Uses the Perses-spec `createDashboardV2`
* and normalizes to an `ExportDashboard`.
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
* `createDashboardV2`; V1 uses the legacy create. Both normalize to an `ExportDashboard`.
*/
export function useCreateExportDashboard({
title,
onCreated,
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
const isDashboardV2 = useIsDashboardV2();
const { showErrorModal } = useErrorModal();
const onError = useCallback(
@@ -31,7 +35,16 @@ export function useCreateExportDashboard({
[showErrorModal],
);
const createDashboardMutation = useMutation(
const v1 = useMutation(createDashboardV1, {
onSuccess: (data) => {
if (data.data) {
onCreated({ id: data.data.id, title: data.data.data.title ?? '' });
}
},
onError,
});
const v2 = useMutation(
() =>
createDashboardV2({
schemaVersion: 'v6',
@@ -53,11 +66,15 @@ export function useCreateExportDashboard({
);
const create = useCallback((): void => {
createDashboardMutation.mutate();
}, [createDashboardMutation]);
if (isDashboardV2) {
v2.mutate();
} else {
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
}
}, [isDashboardV2, v1, v2, title]);
return {
create,
isLoading: createDashboardMutation.isLoading,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
};
}

View File

@@ -1,12 +1,15 @@
import { useCallback, useMemo } from 'react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useDebounce from 'hooks/useDebounce';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
const V2_LIST_LIMIT = 1000;
const SEARCH_DEBOUNCE_MS = 300;
/** Neutral id+title the picker uses in place of the dashboard entity. */
/** Neutral id+title the picker uses in place of the V1/V2 dashboard entity. */
export interface ExportDashboard {
id: string;
title: string;
@@ -21,12 +24,29 @@ export interface UseExportDashboardsResult {
refetch: () => void;
}
function toExportDashboard(
function fromV2(
item: DashboardtypesListedDashboardForUserV2DTO,
): ExportDashboard {
return { id: item.id, title: item.spec.display?.name || item.name };
}
function fromV1(dashboard: Dashboard): ExportDashboard {
return { id: dashboard.id, title: dashboard.data.title ?? '' };
}
function filterByTitle(
dashboards: ExportDashboard[],
search: string,
): ExportDashboard[] {
const term = search.trim().toLowerCase();
if (!term) {
return dashboards;
}
return dashboards.filter((dashboard) =>
dashboard.title.toLowerCase().includes(term),
);
}
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
// as a name-contains clause (single quotes escaped).
function toNameQuery(search: string): string | undefined {
@@ -34,28 +54,37 @@ function toNameQuery(search: string): string | undefined {
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
}
/** Picker source: searches the V2 dashboard list server-side (debounced). */
/** Flag-aware picker source: V2 searches server-side (debounced), V1 filters in memory. */
export function useExportDashboards(search = ''): UseExportDashboardsResult {
const isDashboardV2 = useIsDashboardV2();
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const listQuery = useListDashboardsForUserV2(
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
const v2 = useListDashboardsForUserV2(
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
{ query: { keepPreviousData: true } },
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
);
const dashboards = useMemo<ExportDashboard[]>(
() => (listQuery.data?.data?.dashboards ?? []).map(toExportDashboard),
[listQuery.data],
() =>
isDashboardV2
? (v2.data?.data?.dashboards ?? []).map(fromV2)
: filterByTitle((v1.data?.data ?? []).map(fromV1), search),
[isDashboardV2, v1.data, v2.data, search],
);
const refetch = useCallback((): void => {
void listQuery.refetch();
}, [listQuery]);
if (isDashboardV2) {
void v2.refetch();
} else {
void v1.refetch();
}
}, [isDashboardV2, v1, v2]);
return {
dashboards,
isLoading: listQuery.isLoading,
isFetching: listQuery.isFetching,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
refetch,
};
}

View File

@@ -1,7 +1,9 @@
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
interface ExportToDashboardLinkParams {
dashboardId: string;
@@ -11,15 +13,25 @@ interface ExportToDashboardLinkParams {
}
/**
* "Add to Dashboard" link builder for the explorers. Targets the V2 panel editor;
* `null` when the panel type has no V2 kind, so callers skip navigation.
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
* editor; V1 uses the legacy new-widget link. `null` (V2 only) when the panel type has no
* V2 kind, so callers skip navigation.
*/
export function useGetExportToDashboardLink(): (
params: ExportToDashboardLinkParams,
) => string | null {
const isDashboardV2 = useIsDashboardV2();
return useCallback(
({ dashboardId, panelType, query }: ExportToDashboardLinkParams) =>
buildExportPanelLink({ dashboardId, panelType, query }),
[],
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
isDashboardV2
? buildExportPanelLink({ dashboardId, panelType, query })
: generateExportToDashboardLink({
query,
panelType,
dashboardId,
widgetId,
}),
[isDashboardV2],
);
}

View File

@@ -0,0 +1,10 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsDashboardV2(): boolean {
const { featureFlags } = useAppContext();
return Boolean(
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_DASHBOARD_V2)
?.active,
);
}

View File

@@ -1,94 +0,0 @@
import {
compareTableColumnValues,
RowData,
} from '../createTableColumnsFromQuery';
// Builds a minimal RowData row. Values are intentionally loosely typed because
// real query responses can put objects/arrays into cells despite RowData's
// declared `string | number` index signature (that mismatch is the bug under test).
const row = (value: unknown, dataIndex = 'col'): RowData =>
({
timestamp: 0,
key: 'k',
[dataIndex]: value,
}) as unknown as RowData;
describe('compareTableColumnValues', () => {
it('sorts numerically when both cells are numbers', () => {
expect(compareTableColumnValues(row(1), row(2), 'col')).toBeLessThan(0);
expect(compareTableColumnValues(row(2), row(1), 'col')).toBeGreaterThan(0);
expect(compareTableColumnValues(row(5), row(5), 'col')).toBe(0);
});
it('sorts numeric-looking strings numerically, not lexically', () => {
// "10" vs "9": numeric branch => 10 - 9 > 0. A string compare would be < 0.
expect(compareTableColumnValues(row('10'), row('9'), 'col')).toBeGreaterThan(
0,
);
});
it('prefers the `<dataIndex>_without_unit` value for numeric comparison', () => {
const a = row('2 ms');
const b = row('10 ms');
a.col_without_unit = 2;
b.col_without_unit = 10;
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
});
it('falls back to locale string compare for non-numeric strings', () => {
expect(compareTableColumnValues(row('abc'), row('abd'), 'col')).toBe(
'abc'.localeCompare('abd'),
);
});
it('treats null/undefined cells as empty string (no "null"/"undefined")', () => {
// Empty string sorts before a real word, and two empties are equal.
expect(compareTableColumnValues(row(null), row('a'), 'col')).toBeLessThan(0);
expect(compareTableColumnValues(row(undefined), row(undefined), 'col')).toBe(
0,
);
// If null coerced to the literal "null", this would sort after "a".
expect(
compareTableColumnValues(row(null), row('a'), 'col'),
).not.toBeGreaterThan(0);
});
it('does not throw when a cell value is an object', () => {
const a = row({ foo: 'bar' });
const b = row({ foo: 'baz' });
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
});
it('does not throw when a cell value is an array', () => {
const a = row([1, 2]);
const b = row([3, 4]);
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
// String([1,2]) === "1,2" < String([3,4]) === "3,4"
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
});
it('does not throw when one cell is numeric and the other is an object', () => {
const a = row(42);
const b = row({ foo: 'bar' });
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
});
it('does not throw for a number cell compared against an "N/A" cell', () => {
// http.status_code column: [null, "200", ...] -> ["N/A", 200, ...]
const numberCell = row(200); // numeric string "200" becomes the number 200
const naCell = row('N/A'); // null becomes the string "N/A"
// Both orderings — antd's sorter compares pairs in both directions.
expect(() =>
compareTableColumnValues(numberCell, naCell, 'col'),
).not.toThrow();
expect(() =>
compareTableColumnValues(naCell, numberCell, 'col'),
).not.toThrow();
expect(typeof compareTableColumnValues(numberCell, naCell, 'col')).toBe(
'number',
);
});
});

View File

@@ -637,21 +637,6 @@ const generateData = (
return data;
};
export const compareTableColumnValues = (
a: RowData,
b: RowData,
dataIndex: string,
): number => {
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
if (!isNaN(valueA) && !isNaN(valueB)) {
return valueA - valueB;
}
return String(a[dataIndex] ?? '').localeCompare(String(b[dataIndex] ?? ''));
};
const generateTableColumns = (
dynamicColumns: DynamicColumns,
renderColumnCell?: QueryTableProps['renderColumnCell'],
@@ -665,8 +650,18 @@ const generateTableColumns = (
title: item.title,
width: QUERY_TABLE_CONFIG.width,
render: renderColumnCell && renderColumnCell[dataIndex],
sorter: (a: RowData, b: RowData): number =>
compareTableColumnValues(a, b, dataIndex),
sorter: (a: RowData, b: RowData): number => {
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
if (!isNaN(valueA) && !isNaN(valueB)) {
return valueA - valueB;
}
return ((a[dataIndex] as string) || '').localeCompare(
(b[dataIndex] as string) || '',
);
},
};
return [...acc, column];

View File

@@ -1,7 +1,15 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
import DashboardPage from './DashboardPage';
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardPageEntry(): JSX.Element {
return <DashboardPageV2 />;
const isDashboardV2 = useIsDashboardV2();
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
}
export default DashboardPageEntry;

View File

@@ -11,9 +11,3 @@
border: 1px solid var(--l3-border) !important;
box-shadow: none !important;
}
/* Highlights the dashboard name inside the delete-confirm title. */
.deleteName {
color: var(--danger-background);
font-weight: var(--font-weight-medium);
}

View File

@@ -27,13 +27,16 @@ import logEvent from 'api/common/logEvent';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import history from 'lib/history';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import DashboardSettings from '../../DashboardSettings';
@@ -42,7 +45,6 @@ import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitl
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
import { useDashboardStore } from '../../store/useDashboardStore';
@@ -82,12 +84,8 @@ function DashboardActions({
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
const { contextHolder: deleteConfirmHolder, confirmDeleteDashboard } =
useDeleteDashboardAction({
dashboardId: dashboard.id,
dashboardName: title,
panelCount: Object.keys(dashboard.spec.panels).length,
});
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
// Open the settings drawer when something in the tree requests it (e.g. the
// variables bar's "Add variable" button).
@@ -134,6 +132,19 @@ function DashboardActions({
}
}, [dashboard.id, title, safeNavigate, showErrorModal]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
void logEvent(DashboardDetailEvents.Deleted, {
dashboardId: dashboard.id,
panelCount: Object.keys(dashboard.spec.panels).length,
});
setIsDeleteOpen(false);
history.replace(ROUTES.ALL_DASHBOARD);
},
});
}, [deleteDashboardMutation, dashboard.id, dashboard.spec.panels]);
const handleOpenSettings = useCallback((): void => {
void logEvent(DashboardDetailEvents.SettingsOpened, {
dashboardId: dashboard.id,
@@ -237,7 +248,7 @@ function DashboardActions({
icon: <Trash2 size={14} />,
danger: true,
disabled: isLocked,
onClick: confirmDeleteDashboard,
onClick: (): void => setIsDeleteOpen(true),
},
);
}
@@ -255,7 +266,6 @@ function DashboardActions({
handleClone,
onLockToggle,
handleEnterFullScreen,
confirmDeleteDashboard,
]);
return (
@@ -338,7 +348,14 @@ function DashboardActions({
isOpen={isJsonEditorOpen}
onClose={(): void => setIsJsonEditorOpen(false)}
/>
{deleteConfirmHolder}
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard"?`}
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
isLoading={deleteDashboardMutation.isLoading}
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}
/>
<SectionTitleModal
open={isNewSectionOpen}
heading="New section"

View File

@@ -1,89 +0,0 @@
import { type ReactNode, useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import {
invalidateListDashboardsForUserV2,
useDeleteDashboardV2,
} from 'api/generated/services/dashboard';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import ROUTES from 'constants/routes';
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
import history from 'lib/history';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './DashboardActions.module.scss';
interface UseDeleteDashboardActionArgs {
dashboardId: string;
dashboardName: string;
panelCount: number;
}
interface UseDeleteDashboardAction {
/** Must be rendered in the calling component for the modal to appear. */
contextHolder: ReactNode;
confirmDeleteDashboard: () => void;
}
/**
* Deletes the open dashboard through the v2 endpoint behind the shared
* destructive confirmation, then drops its local preferences, refreshes the
* dashboard list cache and sends the user back to the list.
*/
export function useDeleteDashboardAction({
dashboardId,
dashboardName,
panelCount,
}: UseDeleteDashboardActionArgs): UseDeleteDashboardAction {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { contextHolder, confirmDelete } = useDeleteConfirm();
const removePreferences = useDashboardPreferencesStore(
(state) => state.removePreferences,
);
const { mutate: deleteDashboard } = useDeleteDashboardV2({
mutation: {
onSuccess: async (): Promise<void> => {
void logEvent(DashboardDetailEvents.Deleted, { dashboardId, panelCount });
removePreferences(dashboardId);
await invalidateListDashboardsForUserV2(queryClient);
toast.success('Dashboard deleted successfully');
history.replace(ROUTES.ALL_DASHBOARD);
},
onError: (error: unknown): void => {
showErrorModal(error as APIError);
},
},
});
const confirmDeleteDashboard = useCallback((): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the
<Typography.Text className={styles.deleteName}>
{' '}
{dashboardName}{' '}
</Typography.Text>
dashboard?
</Typography.Title>
),
content: 'This action cannot be undone.',
// Keeps the Delete button loading until the mutation settles, then closes.
onConfirm: () =>
new Promise<void>((resolve) => {
deleteDashboard(
{ pathParams: { id: dashboardId } },
{ onSettled: () => resolve() },
);
}),
});
}, [confirmDelete, dashboardName, deleteDashboard, dashboardId]);
return { contextHolder, confirmDeleteDashboard };
}

View File

@@ -113,15 +113,3 @@
align-items: center;
gap: 4px;
}
// Hidden tags revealed on hovering the `+N` badge: the same chips as inline, one per
// line (easier to scan than a wrapped cloud) and width-capped so a long tag wraps
// instead of stretching the tooltip off-screen.
.overflowTags {
display: flex;
max-width: 360px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
overflow-wrap: anywhere;
}

View File

@@ -20,9 +20,6 @@ import { linkifyText } from 'utils/linkifyText';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/TooltipScrollArea';
import TagsOverflowTooltip from './TagsOverflowTooltip';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
@@ -234,10 +231,7 @@ function DashboardInfo({
<TagBadge key={tag}>{tag}</TagBadge>
))}
{remainingTags.length > 0 && (
<TooltipSimple
title={<TagsOverflowTooltip tags={remainingTags} />}
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
>
<TooltipSimple title={remainingTags.join(', ')}>
<span data-testid="dashboard-tags-overflow">
<TagBadge>+{remainingTags.length}</TagBadge>
</span>

View File

@@ -1,23 +0,0 @@
import TagBadge from 'components/TagBadge/TagBadge';
import TooltipScrollArea from 'components/TooltipScrollArea/TooltipScrollArea';
import styles from './DashboardInfo.module.scss';
interface TagsOverflowTooltipProps {
/** The tags the cluster isn't showing inline. */
tags: string[];
}
function TagsOverflowTooltip({ tags }: TagsOverflowTooltipProps): JSX.Element {
return (
<TooltipScrollArea>
<div className={styles.overflowTags} data-testid="dashboard-tags-tooltip">
{tags.map((tag) => (
<TagBadge key={tag}>{tag}</TagBadge>
))}
</div>
</TooltipScrollArea>
);
}
export default TagsOverflowTooltip;

View File

@@ -1,21 +0,0 @@
import { render, screen } from '@testing-library/react';
import TagsOverflowTooltip from '../TagsOverflowTooltip';
describe('TagsOverflowTooltip', () => {
it('renders every hidden tag as its own chip', () => {
render(
<TagsOverflowTooltip tags={['production', 'team-checkout', 'tier-1']} />,
);
const chips = screen
.getByTestId('dashboard-tags-tooltip')
.querySelectorAll('[data-slot="badge"]');
expect(Array.from(chips).map((chip) => chip.textContent)).toStrictEqual([
'production',
'team-checkout',
'tier-1',
]);
});
});

View File

@@ -2,7 +2,7 @@
from the collapsible config sections above by the same hairline divider. */
.divider {
height: 1px;
background: var(--l1-border);
background: var(--l2-border);
margin: 18px 0;
}

View File

@@ -1,4 +1,4 @@
import { Flame } from '@signozhq/icons';
import { Bell } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
@@ -38,7 +38,7 @@ function ConfigActions({
<div className={styles.list}>
<ConfigActionRow
testId="panel-editor-v2-create-alert"
icon={<Flame size={14} />}
icon={<Bell size={14} />}
label="Create alert"
onClick={(): void => createAlert(panel, panelId)}
/>

View File

@@ -1,5 +1,3 @@
@use '../../../../../styles/scrollbar' as *;
.config {
display: flex;
flex-direction: column;
@@ -8,24 +6,33 @@
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 44px;
@include custom-scrollbar;
//TODO: replace this with custom-scrollbar mixin
// Thin, unobtrusive scrollbar (replaces the chunky native bar).
$thumb: color-mix(in srgb, var(--bg-vanilla-100) 16%, transparent);
scrollbar-width: thin;
scrollbar-color: $thumb transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: $thumb;
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
}
.heading {
padding: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.marker {
display: inline-block;
width: 8px;
height: 4px;
border-radius: 20px;
background-color: var(--primary);
margin-bottom: 18px;
padding: 16px 16px 0 16px;
}
.title {
@@ -42,10 +49,11 @@
.eyebrow {
display: block;
margin: 0 2px 10px;
font-size: 11px;
font-weight: 600;
padding: 16px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l1-foreground);
}
@@ -53,7 +61,7 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
padding: 0 16px;
}
.field {
@@ -63,19 +71,20 @@
}
.divider {
// flex-shrink:0 keeps the 1px line from collapsing to 0 once the pane
// content overflows and the flex column starts shrinking its children.
flex-shrink: 0;
height: 1px;
background: var(--l1-border);
background: var(--l2-border);
margin: 18px 0;
}
.sectionsContainer {
padding: 0 16px;
}
.sections {
display: flex;
flex-direction: column;
& > * {
padding: 0 16px;
border-top: 1px solid var(--l1-border);
& > * + * {
border-top: 1px solid var(--l2-border);
}
}

View File

@@ -77,10 +77,8 @@ function ConfigPane({
return (
<div className={styles.config}>
<header className={styles.heading}>
<span className={styles.marker} />
<Typography.Text>Panel Details</Typography.Text>
<Typography.Text>Panel settings</Typography.Text>
</header>
<div className={styles.divider} />
<div className={styles.group}>
<div className={styles.field}>
@@ -110,7 +108,7 @@ function ConfigPane({
<>
<div className={styles.divider} />
<div className={styles.sectionsContainer}>
<span className={styles.eyebrow}>DISPLAY OPTIONS</span>
<span className={styles.eyebrow}>Display</span>
<div className={styles.sections}>
{sections.map((config) => (
<SectionSlot

View File

@@ -1,39 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface SectionHeaderQuickAddConfig {
label: string;
testId: string;
}
interface SectionHeaderQuickAddProps {
action: SectionHeaderQuickAddConfig;
/** Expands the section and runs the editor's registered add handler. */
onClick: () => void;
}
/** Quick-add control rendered in a configuration section's header, beside the chevron. */
function SectionHeaderQuickAdd({
action,
onClick,
}: SectionHeaderQuickAddProps): JSX.Element {
return (
<TooltipSimple title="Quick Add" side="top" arrow>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={action.label}
// Not `testId`: TooltipTrigger's Slot merge overwrites it with undefined.
data-testid={action.testId}
onClick={onClick}
>
<Plus size={15} />
</Button>
</TooltipSimple>
);
}
export default SectionHeaderQuickAdd;

View File

@@ -1,4 +1,3 @@
import { type ReactNode, useCallback, useRef, useState } from 'react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
type PanelFormattingSlice,
@@ -10,41 +9,19 @@ import {
import type { SectionEditorContext } from '../sectionContext';
import { resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
// `yAxisUnit` is derived from the spec below, not forwarded, so it's omitted.
type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
// Per-section header content; `trigger` expands the section and runs the editor's handler.
const SECTION_HEADER_SLOT: Partial<
Record<SectionKind, (trigger: () => void) => ReactNode>
> = {
[SectionKind.Thresholds]: (trigger): ReactNode => (
<SectionHeaderQuickAdd
action={{
label: 'Add Threshold',
testId: 'panel-editor-v2-add-threshold-header',
}}
onClick={trigger}
/>
),
[SectionKind.ContextLinks]: (trigger): ReactNode => (
<SectionHeaderQuickAdd
action={{
label: 'Add Context Link',
testId: 'panel-editor-v2-add-link-header',
}}
onClick={trigger}
/>
),
};
} & Omit<SectionEditorContext, 'yAxisUnit'>;
/**
* Renders one configuration section: its collapsible wrapper plus the registered editor
* for `config.kind`. Renders nothing when the kind has no editor yet.
* for `config.kind`, wired through the registry's spec lens. Renders nothing when the
* kind has no editor yet (sections roll out incrementally), so a kind can declare a
* section before its editor exists.
*/
function SectionSlot({
config,
@@ -59,43 +36,13 @@ function SectionSlot({
stepInterval,
metricUnit,
}: SectionSlotProps): JSX.Element | null {
const editor = resolveSectionEditor(config.kind);
// Controlled so the header slot can expand on click; list sections open when populated.
const [open, setOpen] = useState(() => {
if (config.kind === SectionKind.Visualization) {
return true;
}
const value = editor?.get(spec);
return Array.isArray(value) && value.length > 0;
});
// The editor mounts only while open, so a collapsed-click defers the handler until it registers.
const actionHandlerRef = useRef<(() => void) | null>(null);
const pendingActionRef = useRef(false);
const registerHeaderAction = useCallback(
(handler: (() => void) | null): void => {
actionHandlerRef.current = handler;
if (handler && pendingActionRef.current) {
pendingActionRef.current = false;
handler();
}
},
[],
);
const triggerHeaderAction = useCallback((): void => {
setOpen(true);
if (actionHandlerRef.current) {
actionHandlerRef.current();
} else {
pendingActionRef.current = true;
}
}, []);
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
if (config.isHidden?.(spec)) {
return null;
}
const editor = resolveSectionEditor(config.kind);
if (!editor) {
return null;
}
@@ -104,19 +51,17 @@ function SectionSlot({
const { Component, get, update } = editor;
// Atomic sections carry no `controls`; controlled ones do.
const controls = 'controls' in config ? config.controls : undefined;
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
// The panel's formatting unit, forwarded to editors that scope to it (thresholds
// restrict their unit picker to this unit's category, as in V1).
const yAxisUnit = (spec.plugin.spec as { formatting?: PanelFormattingSlice })
.formatting?.unit;
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
return (
<SettingsSection
title={title}
icon={<Icon size={15} />}
open={open}
onOpenChange={setOpen}
headerSlot={headerSlot}
// Open Visualization by default so the type switcher is visible.
defaultOpen={config.kind === SectionKind.Visualization}
>
<Component
value={get(spec)}
@@ -131,7 +76,6 @@ function SectionSlot({
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
</SettingsSection>
);

View File

@@ -1,72 +0,0 @@
import { useState } from 'react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
type SectionConfig,
SectionKind,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent } from 'tests/test-utils';
import SectionSlot from '../SectionSlot';
const THRESHOLDS_CONFIG: SectionConfig = {
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
};
function makeSpec(thresholds: unknown[] = []): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { thresholds } },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
// Stateful harness so onChange feeds back into the spec (as ConfigPane owns it).
function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(
makeSpec(initial),
);
return (
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
);
}
describe('SectionSlot header action', () => {
it('shows the header "+" while the section is collapsed', () => {
render(<Harness />);
// Collapsed: body (inline add) hidden, but the header quick-add is available.
expect(
screen.queryByTestId('panel-editor-v2-add-threshold'),
).not.toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-add-threshold-header'),
).toBeInTheDocument();
});
it('starts expanded when the section already has items', () => {
render(
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
);
// Body is shown on mount (no header click needed) because content exists.
expect(
screen.getByTestId('panel-editor-v2-add-threshold'),
).toBeInTheDocument();
expect(screen.getByText('High')).toBeInTheDocument();
});
it('expands the section and adds a threshold when the header "+" is clicked', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByTestId('panel-editor-v2-add-threshold-header'));
// Expanded, with a fresh row opened in edit mode.
expect(
screen.getByTestId('panel-editor-v2-add-threshold'),
).toBeInTheDocument();
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
});
});

View File

@@ -1,19 +1,10 @@
.header {
display: flex;
align-items: center;
gap: 6px;
gap: 11px;
width: 100%;
height: 44px;
}
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
.toggle {
display: flex;
flex: 1;
align-items: center;
gap: 11px;
min-width: 0;
padding: 0 !important;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
@@ -46,6 +37,8 @@
}
.chevron {
flex: none;
color: var(--l2-border);
transition: transform 0.15s ease;
&.open {

View File

@@ -1,6 +1,5 @@
import { type ReactNode, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
@@ -10,74 +9,45 @@ interface SettingsSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
/** Controlled open state; when set, the section defers to `onOpenChange`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Rendered between the title and the chevron. */
headerSlot?: ReactNode;
children: ReactNode;
}
/**
* Collapsible container for one configuration section in the V2 panel editor's ConfigPane.
* Collapsible container for one configuration section in the V2 panel editor's
* ConfigPane. Header shows an icon tile (accented when expanded), the title, and a
* rotating chevron; sections are separated by hairline dividers (no surrounding boxes),
* matching the Configure-panel design.
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
open,
onOpenChange,
headerSlot,
children,
}: SettingsSectionProps): JSX.Element {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const toggle = (): void => {
const next = !isOpen;
if (!isControlled) {
setInternalOpen(next);
}
onOpenChange?.(next);
};
const [isOpen, setIsOpen] = useState(defaultOpen);
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
return (
<section className={styles.section}>
<div className={styles.header}>
<button
type="button"
className={styles.toggle}
aria-expanded={isOpen}
data-testid={`config-section-${serializedTitle}`}
onClick={toggle}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
</button>
{headerSlot}
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
prefix={
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
tabIndex={-1}
onClick={toggle}
<button
type="button"
className={styles.header}
aria-expanded={isOpen}
data-testid={`config-section-${serializedTitle}`}
onClick={(): void => setIsOpen((prev) => !prev)}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
</div>
</button>
{isOpen && <div className={styles.body}>{children}</div>}
</section>
);

View File

@@ -1,56 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsSection from '../SettingsSection';
describe('SettingsSection', () => {
it('renders an arbitrary headerSlot node beside the header', () => {
render(
<SettingsSection
title="Thresholds"
headerSlot={
<button type="button" aria-label="custom action" data-testid="my-action" />
}
>
<div>body</div>
</SettingsSection>,
);
expect(screen.getByTestId('my-action')).toBeInTheDocument();
});
it('is collapsed by default: hides the body until the header is clicked', async () => {
const user = userEvent.setup();
render(
<SettingsSection title="Thresholds">
<div data-testid="body">body</div>
</SettingsSection>,
);
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
await user.click(screen.getByTestId('config-section-thresholds'));
expect(screen.getByTestId('body')).toBeInTheDocument();
});
it('defers to onOpenChange when open is controlled', async () => {
const user = userEvent.setup();
const onOpenChange = jest.fn();
const { rerender } = render(
<SettingsSection title="Thresholds" open={false} onOpenChange={onOpenChange}>
<div data-testid="body">body</div>
</SettingsSection>,
);
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
await user.click(screen.getByTestId('config-section-thresholds'));
expect(onOpenChange).toHaveBeenCalledWith(true);
rerender(
<SettingsSection title="Thresholds" open onOpenChange={onOpenChange}>
<div data-testid="body">body</div>
</SettingsSection>,
);
expect(screen.getByTestId('body')).toBeInTheDocument();
});
});

View File

@@ -1,13 +1,20 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import { EQueryType } from 'types/common/dashboard';
import ConfigPane from '../ConfigPane';
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
}),
);
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -33,23 +40,7 @@ function renderConfigPane(
panelId: 'panel-1',
...overrides,
};
// Stateful so typed edits feed back into the spec, as the panel editor owns it.
function Harness(): JSX.Element {
const [currentSpec, setCurrentSpec] = useState(props.spec);
return (
<ConfigPane
{...props}
spec={currentSpec}
onChangeSpec={(next): void => {
props.onChangeSpec(next);
setCurrentSpec(next);
}}
/>
);
}
render(<Harness />);
render(<ConfigPane {...props} />);
return props;
}
@@ -63,15 +54,14 @@ describe('ConfigPane', () => {
);
});
it('reports title edits through onChangeSpec (into spec.display)', async () => {
const user = userEvent.setup();
it('reports title edits through onChangeSpec (into spec.display)', () => {
const { onChangeSpec } = renderConfigPane();
const title = screen.getByTestId('panel-editor-v2-title');
await user.clear(title);
await user.type(title, 'Memory');
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
target: { value: 'Memory' },
});
expect(onChangeSpec).toHaveBeenLastCalledWith(
expect(onChangeSpec).toHaveBeenCalledWith(
expect.objectContaining({
display: { name: 'Memory', description: 'usage' },
}),

View File

@@ -1,10 +1,6 @@
// Fill the section field so the select lines up with the other full-width controls.
.select {
width: 100%;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.item {

View File

@@ -5,7 +5,7 @@
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 2px;
border-radius: 6px;
background: var(--l2-background-60);
}

View File

@@ -1,5 +1,3 @@
@use '../../../../../../../styles/scrollbar' as *;
.container {
display: flex;
flex-direction: column;
@@ -7,7 +5,6 @@
}
.list {
@include custom-scrollbar;
width: 100%;
}

View File

@@ -21,6 +21,4 @@ export interface SectionEditorContext {
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
/** An editor registers the handler its header action (e.g. a quick-add "+") triggers; `null` to clear. */
registerHeaderAction?: (handler: (() => void) | null) => void;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { DashboardtypesLinkDTO } from 'api/generated/services/sigNoz.schemas';
@@ -7,7 +7,6 @@ import type {
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { SectionEditorContext } from '../../sectionContext';
import ContextLinkDialog from './ContextLinkDialog';
import ContextLinkListItem from './ContextLinkListItem';
import { useContextLinkVariables } from './useContextLinkVariables';
@@ -22,9 +21,7 @@ import styles from './ContextLinksSection.module.scss';
function ContextLinksSection({
value,
onChange,
registerHeaderAction,
}: SectionEditorProps<SectionKind.ContextLinks> &
Pick<SectionEditorContext, 'registerHeaderAction'>): JSX.Element {
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
const links = value ?? [];
const variables = useContextLinkVariables();
@@ -34,16 +31,6 @@ function ContextLinksSection({
index: null,
});
const openAddDialog = useCallback(
(): void => setDialog({ open: true, index: null }),
[],
);
useEffect(() => {
registerHeaderAction?.(openAddDialog);
return (): void => registerHeaderAction?.(null);
}, [registerHeaderAction, openAddDialog]);
const removeAt = (index: number): void =>
onChange(links.filter((_, i) => i !== index));
@@ -79,7 +66,7 @@ function ContextLinksSection({
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-link"
onClick={openAddDialog}
onClick={(): void => setDialog({ open: true, index: null })}
>
Add Context Link
</Button>

View File

@@ -8,9 +8,6 @@
:global(.ant-select) {
width: 100%;
}
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
// Stacked per-column unit pickers; each column keeps the standard field layout.

View File

@@ -96,9 +96,6 @@
:global(.ant-select) {
width: 100%;
}
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.invalidUnit {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
@@ -63,10 +63,7 @@ type ThresholdsSectionProps = {
/** `variant` picks the row editor + element shape; defaults to `label`. */
controls?: { variant?: ThresholdVariant };
onChange: (next: AnyThreshold[]) => void;
} & Pick<
SectionEditorContext,
'yAxisUnit' | 'tableColumns' | 'registerHeaderAction'
>;
} & Pick<SectionEditorContext, 'yAxisUnit' | 'tableColumns'>;
/**
* Edits the `thresholds` slice for every panel kind. All variants share the same
@@ -80,7 +77,6 @@ function ThresholdsSection({
onChange,
yAxisUnit,
tableColumns = [],
registerHeaderAction,
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? ThresholdVariant.LABEL;
const thresholds = value ?? [];
@@ -97,17 +93,12 @@ function ThresholdsSection({
onChange(thresholds.map((t, i) => (i === index ? next : t)));
};
const addThreshold = useCallback((): void => {
const current = value ?? [];
onChange([...current, defaultThreshold(variant, tableColumns)]);
setEditingIndex(current.length);
setUnsavedIndex(current.length);
}, [value, onChange, variant, tableColumns]);
useEffect(() => {
registerHeaderAction?.(addThreshold);
return (): void => registerHeaderAction?.(null);
}, [registerHeaderAction, addThreshold]);
const addThreshold = (): void => {
const nextIndex = thresholds.length;
onChange([...thresholds, defaultThreshold(variant, tableColumns)]);
setEditingIndex(nextIndex);
setUnsavedIndex(nextIndex);
};
const beginEdit = (index: number): void => {
editSnapshot.current = thresholds[index] ?? null;

View File

@@ -1,12 +1,10 @@
import { useCallback } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
@@ -14,7 +12,6 @@ import DisabledControlTooltip from '../../components/DisabledControlTooltip/Disa
import styles from './Header.module.scss';
interface HeaderProps {
/** Unsaved edits exist — shows the "Unsaved Changes" badge and gates the discard confirmation on close (not the Save button). */
isDirty: boolean;
isSaving: boolean;
showSwitchToView?: boolean;
@@ -68,18 +65,8 @@ function Header({
/>
<Divider type="vertical" />
<Typography.Text>Configure panel</Typography.Text>
{isDirty && (
<Badge color="warning" data-testid="panel-editor-v2-unsaved-badge">
Unsaved Changes
</Badge>
)}
</div>
<div className={styles.actions}>
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback={false}
/>
{showSwitchToView && (
<Button
variant="outlined"
@@ -95,7 +82,7 @@ function Header({
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={readOnly || isSaving}
disabled={readOnly || !isDirty || isSaving}
loading={!readOnly && isSaving}
onClick={readOnly ? undefined : onSave}
>

View File

@@ -1,103 +0,0 @@
import type { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import Header from '../Header';
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: jest.fn(),
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: (): unknown => ({
isCloudUser: true,
isEnterpriseSelfHostedUser: false,
}),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseIsAIAssistantEnabled = useIsAIAssistantEnabled as jest.Mock;
function renderHeader(
props: Partial<ComponentProps<typeof Header>> = {},
): void {
// AppLayout supplies the TooltipProvider in the app; the header is rendered bare here.
render(
<MemoryRouter>
<TooltipProvider>
<Header
isDirty={false}
isSaving={false}
onSave={jest.fn()}
onClose={jest.fn()}
{...props}
/>
</TooltipProvider>
</MemoryRouter>,
);
}
describe('PanelEditor Header', () => {
afterEach(() => {
mockUseIsAIAssistantEnabled.mockReset();
});
// The editor is a full page, so the side nav's Noz entry point is gone while it is
// open — the header has to offer it instead.
it('offers Noz alongside the editor actions', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(true);
renderHeader();
expect(screen.getByRole('button', { name: 'Open Noz' })).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-close')).toBeInTheDocument();
});
it('omits Noz when the AI assistant is disabled', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader();
expect(
screen.queryByRole('button', { name: 'Open Noz' }),
).not.toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
});
it('keeps Save enabled even when there are no unsaved edits', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader({ isDirty: false });
expect(screen.getByTestId('panel-editor-v2-save')).toBeEnabled();
});
it('disables Save only while read-only or saving', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader({ isDirty: true, readOnly: true, readOnlyReason: 'Locked' });
expect(screen.getByTestId('panel-editor-v2-save')).toBeDisabled();
});
it('shows the Unsaved Changes badge only when there are unsaved edits', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader({ isDirty: false });
expect(
screen.queryByTestId('panel-editor-v2-unsaved-badge'),
).not.toBeInTheDocument();
renderHeader({ isDirty: true });
expect(
screen.getByTestId('panel-editor-v2-unsaved-badge'),
).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,5 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -101,12 +100,6 @@ jest.mock('@signozhq/ui/resizable', () => ({
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockShowErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: mockShowErrorModal,
}),
}));
// Children mocked to capture props (and expose a Save trigger / footer slot).
const mockHeaderProps = jest.fn();
@@ -181,13 +174,11 @@ const baseProps = {
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
draftOverrides?: { isSpecDirty?: boolean; draft?: DashboardtypesPanelDTO },
draftOverrides?: { isSpecDirty?: boolean },
): void {
// The live draft can diverge from the seed `panel`; default to the seed.
const draftPanel = draftOverrides?.draft ?? panel;
mockUseDraft.mockReturnValue({
draft: draftPanel,
spec: draftPanel.spec,
draft: panel,
spec: panel.spec,
setSpec: mockSetSpec,
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
});
@@ -268,36 +259,19 @@ describe('PanelEditorContainer composition', () => {
);
});
it('keeps a query-less new panel clean but still serializes its seed query', () => {
it('keeps a query-less new panel unsaveable but still serializes its seed query', () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({ alwaysSerializeQuery: true }),
);
// No query and no edits yet → not dirty, so closing won't prompt to discard.
// No query and no edits yet → nothing to save, so Save stays disabled.
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: false }),
);
});
it('keeps a query-less new panel clean after the staged-query sync seeds its draft (regression)', () => {
// Staged-query sync populated the draft with the seed; dirty must read the seed
// `panel` (query-less), not the draft, else an untouched new panel reads dirty.
const committedDraft = makePanel('signoz/TimeSeriesPanel', [
{ spec: { plugin: { kind: 'signoz/CompositeQuery', spec: {} } } },
]);
setup(
makePanel('signoz/TimeSeriesPanel'),
{ isNew: true },
{ draft: committedDraft },
);
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: false }),
);
});
it('marks a new panel that already has a query dirty (e.g. list auto-runs one)', () => {
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
const seededQuery = {
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
};
@@ -334,24 +308,6 @@ describe('PanelEditorContainer composition', () => {
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('surfaces a save failure through the error modal', async () => {
// The raw thrown value flows straight to the modal, which normalizes it to an
// APIError itself (that normalization is covered by ErrorModalProvider's tests).
const failure = {
response: {
status: 400,
data: { error: { code: 'INVALID', message: 'Panel name already exists' } },
},
};
mockSave.mockRejectedValueOnce(failure);
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => expect(mockShowErrorModal).toHaveBeenCalledWith(failure));
expect(toast.error).not.toHaveBeenCalled();
});
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));

View File

@@ -2,7 +2,6 @@ import { renderHook, waitFor } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -10,7 +9,7 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
// Exercises the REAL query builder provider (not mocks) so the dirty check is
@@ -46,28 +45,6 @@ function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
} as unknown as DashboardtypesPanelDTO;
}
/** Providers whose URL already carries `query` as the compositeQuery param (a mid-edit refresh). */
function makeUrlWrapper(
query: Query,
): ({ children }: { children: React.ReactNode }) => JSX.Element {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
return function UrlWrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<AllTheProviders initialRoute={`/?${params.toString()}`}>
{children}
</AllTheProviders>
);
};
}
describe('usePanelEditorQuerySync (real query builder)', () => {
it('an untouched existing panel is NOT query-dirty on mount', async () => {
const saved = makeSavedQueries();
@@ -90,76 +67,6 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
expect(result.current.isQueryDirty).toBe(false);
});
it.each([
[DataSource.METRICS, PANEL_TYPES.TIME_SERIES],
[DataSource.LOGS, PANEL_TYPES.TIME_SERIES],
[DataSource.TRACES, PANEL_TYPES.TIME_SERIES],
[DataSource.LOGS, PANEL_TYPES.LIST],
])(
'a NEW %s panel (%s, no savedQueries) is NOT query-dirty on mount',
async (ds, pt) => {
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel([]),
panelType: pt,
setSpec: jest.fn(),
refetch: jest.fn(),
alwaysSerializeQuery: true,
signal: ds as unknown as TelemetrytypesSignalDTO,
// savedQueries omitted — new panel.
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
expect(result.current.isQueryDirty).toBe(false);
},
);
it('a NEW panel stays query-dirty after Stage & Run commits the edited query into the draft', async () => {
// Repro of the P0: new panel → edit → Run commits the query into the draft; the
// dirty baseline must not drift onto it, or Save re-disables.
const editedInUrl: Query = {
...initialQueriesMap[DataSource.METRICS],
id: 'edited-new-panel',
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'edited-legend',
},
],
},
};
const committed = toPerses(editedInUrl, panelType);
const { result, rerender } = renderHook(
({ draftQueries }: { draftQueries: DashboardtypesQueryDTO[] }) =>
usePanelEditorQuerySync({
draft: makePanel(draftQueries),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
alwaysSerializeQuery: true,
signal: DataSource.METRICS as unknown as TelemetrytypesSignalDTO,
// savedQueries omitted — new panel.
}),
{
wrapper: makeUrlWrapper(editedInUrl),
initialProps: { draftQueries: [] as DashboardtypesQueryDTO[] },
},
);
// The edited query (from the URL) diverges from the seeded default → dirty.
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
// Stage & Run commits the edited query into the draft → must stay dirty.
rerender({ draftQueries: committed });
expect(result.current.isQueryDirty).toBe(true);
});
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
// An older saved query carries only a few fields; the builder re-emits many more
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
@@ -205,80 +112,6 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
expect(result.current.isQueryDirty).toBe(false);
});
it('a saved panel with no `having` is NOT dirty on mount (builder seeds an empty having)', async () => {
// Reproduces the reported bug: a panel saved as a bare signoz/BuilderQuery that never
// carried a `having`. On editor open the builder hydrates from the URL and
// prepareQueryBuilderData seeds an empty `{ expression: '' }`, so a verbatim envelope
// compare flags the untouched panel as dirty. Seed via the URL (not resetQuery) so the
// hydration path that synthesizes the having actually runs.
const savedNoHaving: DashboardtypesQueryDTO[] = [
{
kind: 'time_series',
spec: {
name: 'A',
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
source: '',
aggregations: [
{
metricName: 'signoz_latency.bucket',
temporality: 'delta',
timeAggregation: '',
spaceAggregation: 'p99',
},
],
disabled: false,
filter: { expression: 'service.name IN $service_name' },
groupBy: [
{
name: 'service.name',
signal: '',
fieldContext: 'resource',
fieldDataType: '',
},
],
order: [
{
key: {
name: '__result',
signal: '',
fieldContext: '',
fieldDataType: '',
},
direction: 'desc',
},
],
limit: 100,
legend: '',
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
// The editor puts the (having-less) seed query into the URL; the provider then hydrates
// from it, which is where the empty having is added.
const seededInUrl = fromPerses(savedNoHaving, panelType);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(savedNoHaving),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: savedNoHaving,
}),
{ wrapper: makeUrlWrapper(seededInUrl) },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
expect(result.current.isQueryDirty).toBe(false);
});
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
// carries the last-run (edited) query. The builder must hydrate from the URL —
@@ -297,8 +130,23 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
],
},
};
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(editedInUrl)),
);
const setSpec = jest.fn();
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<AllTheProviders initialRoute={`/?${params.toString()}`}>
{children}
</AllTheProviders>
);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
@@ -309,7 +157,7 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper: makeUrlWrapper(editedInUrl) },
{ wrapper },
);
// The URL edit is retained → dirty, and it's synced into the draft so the

View File

@@ -79,11 +79,6 @@ export function usePanelEditorQuerySync({
// in-editor edit in the URL survives a refresh / browser Back-Forward.
useShareBuilderUrl({ defaultValue: seedQuery });
// Frozen seed for the new-panel dirty baseline: Stage & Run commits the live query
// into `draft.spec.queries`, so re-reading `seedQuery` would drift the baseline onto
// the run query and Save would re-disable right after a run.
const initialSeedRef = useRef(seedQuery);
// Commit the live query into the draft (what the preview fetches).
const commitQuery = useCallback(
(query: Query): boolean => {
@@ -157,19 +152,16 @@ export function usePanelEditorQuerySync({
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
// stored query) reading an untouched panel as modified. New panel: fall back to the
// frozen initial seed (see `initialSeedRef`).
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
const baselineEnvelopes = useMemo(
() =>
toQueryEnvelopes(
toPerses(
savedQueries
? fromPerses(savedQueries, panelType)
: initialSeedRef.current,
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
panelType,
),
),
[savedQueries, panelType],
[savedQueries, seedQuery, panelType],
);
const isQueryDirty = useMemo(
() =>

View File

@@ -19,7 +19,6 @@ import {
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
@@ -160,13 +159,11 @@ function PanelEditorContainer({
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
// A new panel is savable once it has a query to run — List auto-seeds one; other
// kinds open query-less, so there's nothing to save until the user builds one.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
@@ -228,7 +225,6 @@ function PanelEditorContainer({
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
@@ -239,22 +235,12 @@ function PanelEditorContainer({
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
toast.success('Panel saved');
onSaved();
} catch (err) {
showErrorModal(err);
} catch {
toast.error('Failed to save panel');
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollTargetId, onSaved]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,

View File

@@ -1,24 +1,16 @@
@use '../../../../../../styles/scrollbar' as *;
// Centred, vertically-stacked panel state (no query / no data / error). Fills
// the panel body below the header and centres its content both axes. When the
// panel is too small to fit icon + text + actions, it scrolls instead of
// clipping the buttons at the edge (`safe center` keeps the top reachable).
// the panel body below the header and centres its content both axes.
.message {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: safe center;
justify-content: center;
gap: 6px;
padding: 16px;
text-align: center;
min-height: 0;
min-width: 0;
overflow: auto;
// Match the shared panel scrollbar (chart legend, table/list panels).
@include custom-scrollbar;
}
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
@@ -59,5 +51,4 @@
justify-content: center;
gap: 8px;
margin-top: 8px;
max-width: 100%;
}

View File

@@ -33,7 +33,7 @@ describe('preparePieData', () => {
]);
});
it('serialises a single group-by column as {key="value"} (V1 parity)', () => {
it('keeps one slice per group row for a single value column', () => {
const table = tableWith(
[
{
@@ -53,59 +53,8 @@ describe('preparePieData', () => {
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['{service.name="adservice"}', 100],
['{service.name="cartservice"}', 200],
]);
});
it('serialises a grouped metrics query as {direction="write"} (V1 parity)', () => {
const table = tableWith(
[
{
name: 'direction',
queryName: 'A',
isValueColumn: false,
id: 'direction',
},
{ name: 'A', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { direction: 'write', A: 3170000000 } },
{ data: { direction: 'read', A: 10000000 } },
],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'{direction="write"}',
'{direction="read"}',
]);
});
it('substitutes a legend format template with the group-by values', () => {
const table = tableWith(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
{ legend: 'service.name = {{service.name}}' },
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'service.name = adservice',
'service.name = cartservice',
['adservice', 100],
['cartservice', 200],
]);
});

View File

@@ -1,6 +1,5 @@
import { themeColors } from 'constants/theme';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
@@ -52,8 +51,7 @@ export function preparePieData({
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
// V1 parity: serialise group-by labels as `{key="value"}`.
label = getLabelName(labels, table.queryName || '', table.legend || '');
label = groupLabel || table.legend || table.queryName || '';
}
const color = customColors?.[label] ?? generateColor(label, colorMap);

View File

@@ -1,60 +0,0 @@
import type { PanelTableColumn } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { formatTableCellText } from '../tableColumns';
jest.mock('../../../utils/formatPanelValue', () => ({
formatPanelValue: (value: number, unit?: string): string =>
`${value}${unit ?? ''}`,
}));
const valueColumn: PanelTableColumn = {
name: 'p99',
queryName: 'A',
isValueColumn: true,
id: 'A',
};
const groupColumn: PanelTableColumn = {
name: 'service',
queryName: 'A',
isValueColumn: false,
id: 'service',
};
describe('formatTableCellText', () => {
it.each([null, undefined, ''])(
'renders empty value-column cells (%p) as n/a instead of 0',
(raw) => {
expect(formatTableCellText(valueColumn, raw, 'ms', undefined)).toBe('n/a');
},
);
it('keeps a real zero as a formatted 0, not n/a', () => {
expect(formatTableCellText(valueColumn, 0, 'ms', undefined)).toBe('0ms');
});
it('formats a numeric value column through unit + precision', () => {
expect(formatTableCellText(valueColumn, 1234, 'ms', undefined)).toBe(
'1234ms',
);
});
it('renders a non-numeric value cell as raw text', () => {
expect(formatTableCellText(valueColumn, 'n/a', 'ms', undefined)).toBe('n/a');
});
it.each([null, undefined, ''])(
'renders empty group-column cells (%p) as n/a',
(raw) => {
expect(formatTableCellText(groupColumn, raw, undefined, undefined)).toBe(
'n/a',
);
},
);
it('renders group-column labels as raw text', () => {
expect(
formatTableCellText(groupColumn, 'frontend', undefined, undefined),
).toBe('frontend');
});
});

View File

@@ -64,19 +64,6 @@ describe('buildTableCsvRows', () => {
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
});
it('exports empty value cells as n/a rather than 0', () => {
const rows = buildTableCsvRows({
table: {
...table,
rows: [{ data: { service: 'api', A: null } }],
},
columnUnits: { A: 'ms' },
decimalPrecision: undefined,
});
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
});
});
describe('getTableCsvRows', () => {

View File

@@ -18,17 +18,6 @@ import styles from './TablePanel.module.scss';
/** A prepared scalar-table row flattened for the antd Table, with the antd key. */
export type TableRowData = Record<string, unknown> & { key: number };
const NA_TEXT = 'n/a';
// Empty cells (null/undefined/'') aren't numbers: `Number(null)` is 0, which
// would render/colour/sort an empty cell as a real zero.
function toCellNumber(raw: unknown): number {
if (raw == null || raw === '') {
return NaN;
}
return Number(raw);
}
/**
* Groups table thresholds by the column they target, mapping each onto the
* V2-native `PanelThreshold` consumed by `resolveActiveThreshold`. A column with
@@ -54,9 +43,6 @@ export function formatTableCellText(
unit: string | undefined,
decimalPrecision?: PrecisionOption,
): string {
if (raw == null || raw === '') {
return NA_TEXT;
}
if (!col.isValueColumn) {
return coerceToString(raw);
}
@@ -70,17 +56,15 @@ export function formatTableCellText(
// Sort comparator: numeric when both cells parse as numbers (value columns and
// numeric group keys), otherwise a locale string compare. Nullish sorts last.
function compareCells(a: unknown, b: unknown): number {
const aNum = toCellNumber(a);
const bNum = toCellNumber(b);
const aNum = Number(a);
const bNum = Number(b);
if (Number.isFinite(aNum) && Number.isFinite(bNum)) {
return aNum - bNum;
}
const aEmpty = a == null || a === '';
const bEmpty = b == null || b === '';
if (aEmpty) {
return bEmpty ? 0 : 1;
if (a == null) {
return b == null ? 0 : 1;
}
if (bEmpty) {
if (b == null) {
return -1;
}
return coerceToString(a).localeCompare(coerceToString(b));
@@ -129,7 +113,7 @@ export function buildTableColumns({
compareCells(a[key], b[key]),
render: (raw: unknown): React.ReactNode => {
const text = formatTableCellText(col, raw, unit, decimalPrecision);
const num = toCellNumber(raw);
const num = Number(raw);
if (
!col.isValueColumn ||
colThresholds.length === 0 ||
@@ -147,7 +131,7 @@ export function buildTableColumns({
const cellProps: React.HTMLAttributes<HTMLElement> = {};
if (col.isValueColumn && colThresholds.length > 0) {
const num = toCellNumber(record[key]);
const num = Number(record[key]);
if (Number.isFinite(num)) {
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {

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