mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-30 01:30:39 +01:00
Compare commits
13 Commits
test/gcp-i
...
monotonic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442d58bccc | ||
|
|
33a0cd043c | ||
|
|
1a6a693466 | ||
|
|
3e35d1ef64 | ||
|
|
1483fbd2c5 | ||
|
|
1255637e25 | ||
|
|
38639847be | ||
|
|
31cb4d7520 | ||
|
|
5ede27e8fb | ||
|
|
5d41c5c016 | ||
|
|
6dcc9f191d | ||
|
|
52df42511b | ||
|
|
08715a704a |
@@ -7238,6 +7238,10 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
type: string
|
||||
ruleId:
|
||||
type: string
|
||||
ruleName:
|
||||
@@ -9930,14 +9934,9 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the services metadata for the specified cloud
|
||||
provider
|
||||
provider, without any account context.
|
||||
operationId: ListServicesMetadata
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
@@ -9987,14 +9986,10 @@ paths:
|
||||
/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets a service for the specified cloud provider
|
||||
description: This endpoint gets a service definition for the specified cloud
|
||||
provider, without any account context.
|
||||
operationId: GetService
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
|
||||
@@ -36,14 +36,12 @@ import type {
|
||||
GetConnectionCredentials200,
|
||||
GetConnectionCredentialsPathParameters,
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccountServicesMetadata200,
|
||||
ListAccountServicesMetadataPathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
ListServicesMetadataParams,
|
||||
ListServicesMetadataPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateAccountPathParameters,
|
||||
@@ -1162,30 +1160,24 @@ export const invalidateGetConnectionCredentials = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the services metadata for the specified cloud provider
|
||||
* This endpoint lists the services metadata for the specified cloud provider, without any account context.
|
||||
* @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,
|
||||
params?: ListServicesMetadataParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
export const getListServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
}: ListServicesMetadataPathParameters) => {
|
||||
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryOptions = <
|
||||
@@ -1193,7 +1185,6 @@ export const getListServicesMetadataQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1205,12 +1196,11 @@ export const getListServicesMetadataQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListServicesMetadataQueryKey({ cloudProvider }, params);
|
||||
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1238,7 +1228,6 @@ export function useListServicesMetadata<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1249,7 +1238,6 @@ export function useListServicesMetadata<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListServicesMetadataQueryOptions(
|
||||
{ cloudProvider },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1266,11 +1254,10 @@ export function useListServicesMetadata<
|
||||
export const invalidateListServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1278,29 +1265,26 @@ export const invalidateListServicesMetadata = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint gets a service for the specified cloud provider
|
||||
* This endpoint gets a service definition for the specified cloud provider, without any account context.
|
||||
* @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,
|
||||
params?: GetServiceParams,
|
||||
) => {
|
||||
export const getGetServiceQueryKey = ({
|
||||
cloudProvider,
|
||||
serviceId,
|
||||
}: GetServicePathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
@@ -1309,7 +1293,6 @@ export const getGetServiceQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1321,12 +1304,11 @@ export const getGetServiceQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
|
||||
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
|
||||
signal,
|
||||
}) => getService({ cloudProvider, serviceId }, params, signal);
|
||||
}) => getService({ cloudProvider, serviceId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1352,7 +1334,6 @@ export function useGetService<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1363,7 +1344,6 @@ export function useGetService<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceQueryOptions(
|
||||
{ cloudProvider, serviceId },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1380,11 +1360,10 @@ 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 }, params) },
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -8320,6 +8320,14 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
overallStateChanged: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedLogsLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedTracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10196,14 +10204,6 @@ 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,14 +10216,6 @@ export type GetServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type GetServiceParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type GetService200 = {
|
||||
data: CloudintegrationtypesServiceDTO;
|
||||
/**
|
||||
|
||||
@@ -380,4 +380,88 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,6 +273,19 @@ 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
|
||||
*/
|
||||
@@ -285,14 +298,22 @@ function convertRawData(
|
||||
queryName: rawData.queryName,
|
||||
legend: legendMap[rawData.queryName] || rawData.queryName,
|
||||
series: null,
|
||||
list: rawData.rows?.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
data: {
|
||||
list: rawData.rows?.map((row) => {
|
||||
const data = {
|
||||
// Map raw data to ILog structure - spread row.data first to include all properties
|
||||
...row.data,
|
||||
date: row.timestamp,
|
||||
} as any,
|
||||
})),
|
||||
} as any;
|
||||
|
||||
if ('body' in row.data) {
|
||||
data.body = extractOnlyMessageBody(row.data.body);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
nextCursor: rawData.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { prepareQueryRangePayloadV5 } from './prepareQueryRangePayloadV5';
|
||||
import {
|
||||
convertBuilderQueriesToV5,
|
||||
prepareQueryRangePayloadV5,
|
||||
} from './prepareQueryRangePayloadV5';
|
||||
|
||||
jest.mock('lib/getStartEndRangeTime', () => ({
|
||||
__esModule: true,
|
||||
@@ -899,3 +902,36 @@ 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +134,21 @@ 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,
|
||||
@@ -181,12 +196,7 @@ function createBaseSpec(
|
||||
)
|
||||
: undefined,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
// 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),
|
||||
having: normalizeHaving(queryData.having),
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map((func: QueryFunction): QueryFunction => {
|
||||
@@ -414,10 +424,7 @@ function createTraceOperatorBaseSpec(
|
||||
)
|
||||
: undefined,
|
||||
legend: isEmpty(legend) ? undefined : legend,
|
||||
// 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),
|
||||
having: normalizeHaving(having),
|
||||
selectFields: isEmpty(nonEmptySelectColumns)
|
||||
? undefined
|
||||
: nonEmptySelectColumns?.map(
|
||||
|
||||
@@ -213,7 +213,9 @@ describe.each([
|
||||
const callArgs = mockDownloadExportData.mock.calls[0][0];
|
||||
const query = callArgs.body.compositeQuery.queries[0];
|
||||
expect(query.spec.groupBy).toBeUndefined();
|
||||
expect(query.spec.having).toStrictEqual({ expression: '' });
|
||||
// An empty having ({ expression: '' }) is a no-op filter and serializes to
|
||||
// undefined — same as the cleared groupBy above.
|
||||
expect(query.spec.having).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
@@ -33,6 +34,7 @@ import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
filterOptionsBySearch,
|
||||
findOptionLabelText,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
SPACEKEY,
|
||||
@@ -1937,7 +1939,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const tag = (
|
||||
<div
|
||||
className={cx('ant-select-selection-item', {
|
||||
'ant-select-selection-item-active': isActive,
|
||||
@@ -1967,13 +1969,32 @@ 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],
|
||||
[
|
||||
isAllSelected,
|
||||
activeChipIndex,
|
||||
selectedChips,
|
||||
selectedValues,
|
||||
maxTagCount,
|
||||
options,
|
||||
],
|
||||
);
|
||||
|
||||
// Simple onClear handler to prevent clearing ALL
|
||||
@@ -1992,51 +2013,58 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<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,
|
||||
// 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,
|
||||
})}
|
||||
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>
|
||||
>
|
||||
{(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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,21 @@ 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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
@@ -1,5 +1,8 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
@@ -389,11 +392,19 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
|
||||
export function toAttributeType(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
if (!metricType) {
|
||||
return '';
|
||||
}
|
||||
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
|
||||
// Monotonicity carries meaning only for cumulative sums: a non-monotonic
|
||||
// cumulative sum is a gauge for all practical purposes. Delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
if (
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative
|
||||
) {
|
||||
return ATTRIBUTE_TYPES.GAUGE;
|
||||
}
|
||||
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
|
||||
|
||||
@@ -167,6 +167,56 @@ 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, '');
|
||||
|
||||
|
||||
@@ -17,6 +17,28 @@ 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,
|
||||
|
||||
@@ -16,17 +16,22 @@ 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 { ListRules200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
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';
|
||||
@@ -100,6 +105,8 @@ 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':
|
||||
@@ -164,6 +171,18 @@ 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;
|
||||
@@ -716,9 +735,11 @@ export default function ChatInput({
|
||||
data: dashboardsResponse,
|
||||
isLoading: isDashboardsLoading,
|
||||
isError: isDashboardsError,
|
||||
} = useGetAllDashboard({
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
|
||||
staleTime: Infinity,
|
||||
} = useListDashboardsForUserV2(DASHBOARD_LIST_PARAMS, {
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -765,12 +786,12 @@ export default function ChatInput({
|
||||
return ctx.resourceId;
|
||||
}
|
||||
if (ctx.type === 'dashboard' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
|
||||
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
|
||||
const cached = queryClient.getQueryData<ListDashboardsForUserV2200>(
|
||||
getListDashboardsForUserV2QueryKey(DASHBOARD_LIST_PARAMS),
|
||||
);
|
||||
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash?.data.title) {
|
||||
return dash.data.title;
|
||||
const dash = cached?.data?.dashboards?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash) {
|
||||
return dashboardTitle(dash);
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
@@ -800,9 +821,9 @@ export default function ChatInput({
|
||||
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
|
||||
{
|
||||
Dashboards:
|
||||
dashboardsResponse?.data?.map((dashboard) => ({
|
||||
dashboardsResponse?.data?.dashboards?.map((dashboard) => ({
|
||||
id: dashboard.id,
|
||||
value: dashboard.data.title ?? 'Untitled',
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
|
||||
@@ -2,12 +2,13 @@ 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('hooks/dashboard/useGetAllDashboard', () => ({
|
||||
useGetAllDashboard: (): unknown => ({
|
||||
data: [],
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -30,22 +31,23 @@ export function getAutoContexts(
|
||||
|
||||
// ── Dashboards ────────────────────────────────────────────────────────────
|
||||
|
||||
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
|
||||
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
|
||||
// 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 }>(
|
||||
pathname,
|
||||
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
|
||||
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
|
||||
);
|
||||
if (widgetMatch) {
|
||||
if (panelEditorMatch) {
|
||||
const { dashboardId, panelId } = panelEditorMatch.params;
|
||||
const isNewPanel = panelId === NEW_PANEL_ID;
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: widgetMatch.params.dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_edit',
|
||||
widgetId: widgetMatch.params.widgetId,
|
||||
...sharedMetadata,
|
||||
},
|
||||
resourceId: dashboardId,
|
||||
metadata: isNewPanel
|
||||
? { page: 'panel_create', ...sharedMetadata }
|
||||
: { page: 'panel_edit', widgetId: panelId, ...sharedMetadata },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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,
|
||||
|
||||
@@ -147,7 +147,6 @@ function ServiceDetails({
|
||||
cloudProvider: type,
|
||||
serviceId: serviceId || '',
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
query: {
|
||||
enabled: !!serviceId && !cloudAccountId,
|
||||
|
||||
@@ -39,9 +39,12 @@ function ServicesList({
|
||||
const {
|
||||
data: providerServicesMetadata,
|
||||
isLoading: isProviderServicesLoading,
|
||||
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
});
|
||||
} = useListServicesMetadata(
|
||||
{ cloudProvider: type },
|
||||
{
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
},
|
||||
);
|
||||
|
||||
const servicesMetadata = hasConnectedAccounts
|
||||
? accountServicesMetadata
|
||||
|
||||
@@ -33,6 +33,7 @@ function AllAttributes({
|
||||
metricName,
|
||||
metricType,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
@@ -71,6 +72,7 @@ function AllAttributes({
|
||||
groupBy,
|
||||
limit,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -89,7 +91,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const goToMetricsExploreWithAppliedAttribute = useCallback(
|
||||
@@ -101,6 +103,7 @@ function AllAttributes({
|
||||
undefined,
|
||||
undefined,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -120,7 +123,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeValue]: value,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const handleKeyMenuItemClick = useCallback(
|
||||
|
||||
@@ -86,6 +86,7 @@ function MetricDetails({
|
||||
undefined,
|
||||
undefined,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -108,6 +109,7 @@ function MetricDetails({
|
||||
handleExplorerTabChange,
|
||||
metadata?.type,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,6 +198,7 @@ function MetricDetails({
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
isMonotonic={metadata?.isMonotonic}
|
||||
temporality={metadata?.temporality}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
|
||||
@@ -147,6 +147,44 @@ describe('MetricDetails utils', () => {
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('should treat a non-monotonic cumulative SUM as a gauge', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.cumulative,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
|
||||
});
|
||||
|
||||
it('should treat a non-monotonic delta SUM as a counter', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.delta,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.SUM,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('should create correct query for GAUGE metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface AllAttributesProps {
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
isMonotonic?: boolean;
|
||||
temporality?: MetrictypesTemporalityDTO;
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
@@ -89,16 +89,21 @@ export function getMetricDetailsQuery(
|
||||
groupBy?: string,
|
||||
limit?: number,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): Query {
|
||||
let timeAggregation;
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
const isNonMonotonicSum =
|
||||
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
|
||||
// Monotonicity carries meaning only for cumulative sums; delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
const isNonMonotonicCumulativeSum =
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative;
|
||||
|
||||
switch (metricType) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
if (isNonMonotonicSum) {
|
||||
if (isNonMonotonicCumulativeSum) {
|
||||
timeAggregation = 'avg';
|
||||
spaceAggregation = 'avg';
|
||||
aggregateOperator = 'avg';
|
||||
@@ -131,7 +136,7 @@ export function getMetricDetailsQuery(
|
||||
break;
|
||||
}
|
||||
|
||||
const attributeType = toAttributeType(metricType, isMonotonic);
|
||||
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
|
||||
|
||||
return {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import {
|
||||
MetricsexplorertypesListMetricDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
@@ -70,7 +71,7 @@ function makeMetric(
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
description: '',
|
||||
temporality: 'cumulative' as never,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
unit: '',
|
||||
...overrides,
|
||||
};
|
||||
@@ -393,12 +394,13 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic Sum metric is treated as Gauge', () => {
|
||||
it('non-monotonic cumulative Sum metric is treated as Gauge', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'active_connections',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -427,6 +429,36 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic delta Sum metric remains a counter with Rate/Increase options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'gcp_reject_connections_count',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: MetrictypesTemporalityDTO.delta,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'gcp_reject_connections_count' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toStrictEqual([
|
||||
'Rate',
|
||||
'Increase',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toStrictEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Histogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
|
||||
@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
|
||||
function getAttributeType(
|
||||
metric: MetricsexplorertypesListMetricDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
return toAttributeType(metric.type, metric.isMonotonic);
|
||||
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
|
||||
}
|
||||
|
||||
function createAutocompleteData(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
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';
|
||||
@@ -687,4 +690,41 @@ 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
PANEL_TYPES,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBaseMeta } from './drilldownUtils';
|
||||
import { getBaseMeta, getOperatorsByDataType } from './drilldownUtils';
|
||||
import { SUPPORTED_OPERATORS } from './menuOptions';
|
||||
import { BreakoutAttributeType } from './types';
|
||||
|
||||
@@ -49,15 +46,9 @@ export function getGroupContextMenuConfig({
|
||||
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
|
||||
const filterKey = clickedData?.column?.dataIndex;
|
||||
|
||||
const filterDataType =
|
||||
getBaseMeta(query, filterKey as string)?.dataType || 'string';
|
||||
const filterDataType = getBaseMeta(query, filterKey as string)?.dataType;
|
||||
|
||||
const operators =
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
];
|
||||
|
||||
const filterOperators = operators.filter(
|
||||
const filterOperators = getOperatorsByDataType(filterDataType).filter(
|
||||
(operator) => SUPPORTED_OPERATORS[operator],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -54,13 +55,44 @@ 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 dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
|
||||
return NUMERIC_DATA_TYPES.includes(dataType);
|
||||
};
|
||||
|
||||
/**
|
||||
* `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;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -637,6 +637,21 @@ 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'],
|
||||
@@ -650,18 +665,8 @@ const generateTableColumns = (
|
||||
title: item.title,
|
||||
width: QUERY_TABLE_CONFIG.width,
|
||||
render: renderColumnCell && renderColumnCell[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) || '',
|
||||
);
|
||||
},
|
||||
sorter: (a: RowData, b: RowData): number =>
|
||||
compareTableColumnValues(a, b, dataIndex),
|
||||
};
|
||||
|
||||
return [...acc, column];
|
||||
|
||||
@@ -113,3 +113,15 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ 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';
|
||||
|
||||
@@ -231,7 +234,10 @@ function DashboardInfo({
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<TooltipSimple
|
||||
title={<TagsOverflowTooltip tags={remainingTags} />}
|
||||
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
|
||||
>
|
||||
<span data-testid="dashboard-tags-overflow">
|
||||
<TagBadge>+{remainingTags.length}</TagBadge>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
@@ -0,0 +1,21 @@
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
from the collapsible config sections above by the same hairline divider. */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
background: var(--l1-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell } from '@signozhq/icons';
|
||||
import { Flame } 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={<Bell size={14} />}
|
||||
icon={<Flame size={14} />}
|
||||
label="Create alert"
|
||||
onClick={(): void => createAlert(panel, panelId)}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../../../styles/scrollbar' as *;
|
||||
|
||||
.config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -6,33 +8,24 @@
|
||||
background-color: var(--l1-background);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 44px;
|
||||
|
||||
//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;
|
||||
}
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 16px 0 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.marker {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -49,11 +42,10 @@
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -61,7 +53,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -71,20 +63,19 @@
|
||||
}
|
||||
|
||||
.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(--l2-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.sectionsContainer {
|
||||
padding: 0 16px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > * + * {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
& > * {
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,10 @@ function ConfigPane({
|
||||
return (
|
||||
<div className={styles.config}>
|
||||
<header className={styles.heading}>
|
||||
<Typography.Text>Panel settings</Typography.Text>
|
||||
<span className={styles.marker} />
|
||||
<Typography.Text>Panel Details</Typography.Text>
|
||||
</header>
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.group}>
|
||||
<div className={styles.field}>
|
||||
@@ -108,7 +110,7 @@ function ConfigPane({
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.sectionsContainer}>
|
||||
<span className={styles.eyebrow}>Display</span>
|
||||
<span className={styles.eyebrow}>DISPLAY OPTIONS</span>
|
||||
<div className={styles.sections}>
|
||||
{sections.map((config) => (
|
||||
<SectionSlot
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type PanelFormattingSlice,
|
||||
@@ -9,19 +10,41 @@ 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'>;
|
||||
} & 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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders one configuration section: its collapsible wrapper plus the registered editor
|
||||
* 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.
|
||||
* for `config.kind`. Renders nothing when the kind has no editor yet.
|
||||
*/
|
||||
function SectionSlot({
|
||||
config,
|
||||
@@ -36,13 +59,43 @@ function SectionSlot({
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (config.isHidden?.(spec)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editor = resolveSectionEditor(config.kind);
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
@@ -51,17 +104,19 @@ function SectionSlot({
|
||||
const { Component, get, update } = editor;
|
||||
// Atomic sections carry no `controls`; controlled ones do.
|
||||
const controls = 'controls' in config ? config.controls : undefined;
|
||||
// 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).
|
||||
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
|
||||
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 Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === SectionKind.Visualization}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
@@ -76,6 +131,7 @@ function SectionSlot({
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
// 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;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -37,8 +46,6 @@
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--l2-border);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.open {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -9,45 +10,74 @@ 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. 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.
|
||||
* Collapsible container for one configuration section in the V2 panel editor's ConfigPane.
|
||||
*/
|
||||
function SettingsSection({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
headerSlot,
|
||||
children,
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
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 serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<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 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>
|
||||
</div>
|
||||
{isOpen && <div className={styles.body}>{children}</div>}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,13 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from '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' },
|
||||
@@ -40,7 +33,23 @@ function renderConfigPane(
|
||||
panelId: 'panel-1',
|
||||
...overrides,
|
||||
};
|
||||
render(<ConfigPane {...props} />);
|
||||
|
||||
// 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 />);
|
||||
return props;
|
||||
}
|
||||
|
||||
@@ -54,14 +63,15 @@ describe('ConfigPane', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports title edits through onChangeSpec (into spec.display)', () => {
|
||||
it('reports title edits through onChangeSpec (into spec.display)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChangeSpec } = renderConfigPane();
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
|
||||
target: { value: 'Memory' },
|
||||
});
|
||||
const title = screen.getByTestId('panel-editor-v2-title');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'Memory');
|
||||
|
||||
expect(onChangeSpec).toHaveBeenCalledWith(
|
||||
expect(onChangeSpec).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
display: { name: 'Memory', description: 'usage' },
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// 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 {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background-60);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5,6 +7,7 @@
|
||||
}
|
||||
|
||||
.list {
|
||||
@include custom-scrollbar;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { DashboardtypesLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -21,7 +22,9 @@ import styles from './ContextLinksSection.module.scss';
|
||||
function ContextLinksSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
|
||||
registerHeaderAction,
|
||||
}: SectionEditorProps<SectionKind.ContextLinks> &
|
||||
Pick<SectionEditorContext, 'registerHeaderAction'>): JSX.Element {
|
||||
const links = value ?? [];
|
||||
const variables = useContextLinkVariables();
|
||||
|
||||
@@ -31,6 +34,16 @@ 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));
|
||||
|
||||
@@ -66,7 +79,7 @@ function ContextLinksSection({
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
data-testid="panel-editor-v2-add-link"
|
||||
onClick={(): void => setDialog({ open: true, index: null })}
|
||||
onClick={openAddDialog}
|
||||
>
|
||||
Add Context Link
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
: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.
|
||||
|
||||
@@ -96,6 +96,9 @@
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.invalidUnit {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -63,7 +63,10 @@ type ThresholdsSectionProps = {
|
||||
/** `variant` picks the row editor + element shape; defaults to `label`. */
|
||||
controls?: { variant?: ThresholdVariant };
|
||||
onChange: (next: AnyThreshold[]) => void;
|
||||
} & Pick<SectionEditorContext, 'yAxisUnit' | 'tableColumns'>;
|
||||
} & Pick<
|
||||
SectionEditorContext,
|
||||
'yAxisUnit' | 'tableColumns' | 'registerHeaderAction'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Edits the `thresholds` slice for every panel kind. All variants share the same
|
||||
@@ -77,6 +80,7 @@ function ThresholdsSection({
|
||||
onChange,
|
||||
yAxisUnit,
|
||||
tableColumns = [],
|
||||
registerHeaderAction,
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
const variant = controls?.variant ?? ThresholdVariant.LABEL;
|
||||
const thresholds = value ?? [];
|
||||
@@ -93,12 +97,17 @@ function ThresholdsSection({
|
||||
onChange(thresholds.map((t, i) => (i === index ? next : t)));
|
||||
};
|
||||
|
||||
const addThreshold = (): void => {
|
||||
const nextIndex = thresholds.length;
|
||||
onChange([...thresholds, defaultThreshold(variant, tableColumns)]);
|
||||
setEditingIndex(nextIndex);
|
||||
setUnsavedIndex(nextIndex);
|
||||
};
|
||||
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 beginEdit = (index: number): void => {
|
||||
editSnapshot.current = thresholds[index] ?? null;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -67,6 +68,11 @@ function Header({
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare={false}
|
||||
enableFeedback={false}
|
||||
/>
|
||||
{showSwitchToView && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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(): 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()}
|
||||
/>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
@@ -174,11 +174,13 @@ const baseProps = {
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
draftOverrides?: { isSpecDirty?: boolean },
|
||||
draftOverrides?: { isSpecDirty?: boolean; draft?: DashboardtypesPanelDTO },
|
||||
): void {
|
||||
// The live draft can diverge from the seed `panel`; default to the seed.
|
||||
const draftPanel = draftOverrides?.draft ?? panel;
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
draft: draftPanel,
|
||||
spec: draftPanel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
|
||||
});
|
||||
@@ -271,6 +273,23 @@ describe('PanelEditorContainer composition', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 saveable (e.g. list auto-runs one)', () => {
|
||||
const seededQuery = {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -9,7 +10,7 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
// Exercises the REAL query builder provider (not mocks) so the dirty check is
|
||||
@@ -45,6 +46,28 @@ 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();
|
||||
@@ -67,6 +90,33 @@ 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('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
|
||||
@@ -112,6 +162,80 @@ 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 —
|
||||
@@ -130,23 +254,8 @@ 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({
|
||||
@@ -157,7 +266,7 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper },
|
||||
{ wrapper: makeUrlWrapper(editedInUrl) },
|
||||
);
|
||||
|
||||
// The URL edit is retained → dirty, and it's synced into the draft so the
|
||||
|
||||
@@ -159,11 +159,12 @@ function PanelEditorContainer({
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// 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.
|
||||
// New panels are savable once seeded with a query (List auto-seeds one). 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.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
|
||||
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
|
||||
);
|
||||
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
@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.
|
||||
// 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).
|
||||
.message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: safe 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
|
||||
@@ -51,4 +59,5 @@
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,32 @@ describe('preparePieData', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefixes the group when multiple value columns are grouped', () => {
|
||||
const table = tableWith(
|
||||
[
|
||||
|
||||
@@ -14,15 +14,16 @@ import type {
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Antenna,
|
||||
BarChart,
|
||||
Columns3,
|
||||
Hash,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
Link2,
|
||||
Palette,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
PencilRuler,
|
||||
Scale3D,
|
||||
Signpost,
|
||||
Wallpaper,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
// Derived from an actual icon component so the type stays exact (size is a
|
||||
@@ -157,14 +158,14 @@ export type SectionConfig =
|
||||
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
|
||||
// live in the ConfigPane section registry.
|
||||
export const SECTION_METADATA = {
|
||||
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: Hash },
|
||||
[SectionKind.Axes]: { title: 'Axes', icon: Ruler },
|
||||
[SectionKind.Legend]: { title: 'Legend', icon: Layers },
|
||||
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: PencilRuler },
|
||||
[SectionKind.Axes]: { title: 'Axes', icon: Scale3D },
|
||||
[SectionKind.Legend]: { title: 'Legend', icon: Signpost },
|
||||
[SectionKind.ChartAppearance]: { title: 'Chart appearance', icon: Palette },
|
||||
[SectionKind.Visualization]: { title: 'Visualization', icon: LayoutDashboard },
|
||||
[SectionKind.Visualization]: { title: 'Visualization', icon: Wallpaper },
|
||||
[SectionKind.Buckets]: { title: 'Histogram / Buckets', icon: BarChart },
|
||||
[SectionKind.Thresholds]: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link },
|
||||
[SectionKind.Thresholds]: { title: 'Thresholds', icon: Antenna },
|
||||
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link2 },
|
||||
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
|
||||
} as const satisfies Record<SectionKind, SectionMetadata>;
|
||||
|
||||
|
||||
@@ -67,6 +67,16 @@
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
// The values behind a pill's `+N`, one bullet each, width-capped so a long value
|
||||
// wraps instead of stretching the tooltip off-screen.
|
||||
.overflowValues {
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// Shared by the Text and value selectors: strips the antd control chrome so the
|
||||
// selector blends into the variable pill.
|
||||
.control {
|
||||
@@ -128,13 +138,3 @@
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.overflowName {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -9,28 +9,13 @@ import { selectVariablesExpanded } from '../store/slices/collapseSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import AddVariableFull from './components/AddVariable/AddVariableFull';
|
||||
import AddVariableIcon from './components/AddVariable/AddVariableIcon';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
|
||||
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
|
||||
import { useVariableSelection } from './hooks/useVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
// Short display of a variable's current selection, for the collapsed +N tooltip.
|
||||
function formatSelection(selection: VariableSelection | undefined): string {
|
||||
if (!selection) {
|
||||
return '—';
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return 'ALL';
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.join(', ') : '—';
|
||||
}
|
||||
return value === '' || value === null || value === undefined
|
||||
? '—'
|
||||
: String(value);
|
||||
}
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
@@ -130,15 +115,12 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
) : (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
|
||||
title={
|
||||
<div className={styles.overflowTooltip}>
|
||||
{hiddenVariables.map((variable) => (
|
||||
<div key={variable.name}>
|
||||
<span className={styles.overflowName}>{variable.name}</span>:{' '}
|
||||
{formatSelection(selection[variable.name])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HiddenVariablesTooltip
|
||||
variables={hiddenVariables}
|
||||
selections={selection}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{moreButton}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import HiddenVariablesTooltip from '../components/HiddenVariablesTooltip/HiddenVariablesTooltip';
|
||||
|
||||
function variable(name: string): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), name };
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
names: string[],
|
||||
selections: VariableSelectionMap,
|
||||
): HTMLElement {
|
||||
render(
|
||||
<HiddenVariablesTooltip
|
||||
variables={names.map(variable)}
|
||||
selections={selections}
|
||||
/>,
|
||||
);
|
||||
return screen.getByTestId('hidden-variables-tooltip');
|
||||
}
|
||||
|
||||
describe('HiddenVariablesTooltip', () => {
|
||||
it('names each hidden variable with a $ prefix, apart from its value', () => {
|
||||
renderTooltip(['env', 'service'], {
|
||||
env: { value: 'production', allSelected: false },
|
||||
service: { value: ['checkout', 'cart'], allSelected: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText('$env')).toBeInTheDocument();
|
||||
expect(screen.getByText('$service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('bullets out a variable holding several values', () => {
|
||||
renderTooltip(['service'], {
|
||||
service: { value: ['checkout', 'cart', 'api'], allSelected: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('listitem').map((item) => item.textContent),
|
||||
).toStrictEqual(['checkout', 'cart', 'api']);
|
||||
});
|
||||
|
||||
it('keeps a lone value unbulleted', () => {
|
||||
renderTooltip(['env'], {
|
||||
env: { value: 'production', allSelected: false },
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels all-selected and unset variables', () => {
|
||||
renderTooltip(['env', 'host'], {
|
||||
env: { value: null, allSelected: true },
|
||||
});
|
||||
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every hidden variable, however many there are', () => {
|
||||
const names = Array.from({ length: 12 }, (_, i) => `var${i}`);
|
||||
|
||||
const tooltip = renderTooltip(names, {});
|
||||
|
||||
expect(tooltip).toHaveTextContent('$var0');
|
||||
expect(tooltip).toHaveTextContent('$var11');
|
||||
});
|
||||
|
||||
it('bullets every value it is given, without truncating', () => {
|
||||
const values = Array.from({ length: 14 }, (_, i) => `v${i}`);
|
||||
|
||||
renderTooltip(['service'], {
|
||||
service: { value: values, allSelected: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import ValueSelector from '../components/selectors/ValueSelector';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const VALUES = ['checkout-service-prod', 'payments-service-prod'];
|
||||
// A strict subset of the options — selecting every option renders as ALL instead.
|
||||
const OPTIONS = [...VALUES, 'cart-service-prod'];
|
||||
|
||||
function renderSelector(
|
||||
selection: VariableSelection,
|
||||
options: string[],
|
||||
multiSelect = true,
|
||||
): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={options}
|
||||
variableType="dynamic"
|
||||
multiSelect={multiSelect}
|
||||
showAllOption
|
||||
selection={selection}
|
||||
onChange={jest.fn()}
|
||||
emptyFallback={{ value: [], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
/>
|
||||
</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('ValueSelector', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("reveals a tag's full value on hovering that tag", async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
// maxTagCount={1} + maxTagTextLength={10} → the one visible tag is cut short.
|
||||
await hover(screen.getByText('checkout-s...'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'checkout-service-prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('reveals the hidden values on hovering the +N overflow', async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
await hover(screen.getByText('+1'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'payments-service-prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reveal anything from the rest of the control', async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
await hover(screen.getByTestId('variable-select-env'));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ALL for an all-selected variable', () => {
|
||||
renderSelector({ value: null, allSelected: true }, ['a', 'b']);
|
||||
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describeSelection } from '../utils/selectionDisplay';
|
||||
|
||||
describe('describeSelection', () => {
|
||||
it('reports an all-selected variable as ALL', () => {
|
||||
expect(describeSelection({ value: null, allSelected: true })).toStrictEqual({
|
||||
kind: 'all',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists a multi-select selection', () => {
|
||||
expect(
|
||||
describeSelection({ value: ['a', 'b'], allSelected: false }),
|
||||
).toStrictEqual({ kind: 'values', values: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('keeps every value, however many there are', () => {
|
||||
const values = Array.from({ length: 30 }, (_, i) => `v${i}`);
|
||||
|
||||
expect(
|
||||
describeSelection({ value: values, allSelected: false }),
|
||||
).toStrictEqual({ kind: 'values', values });
|
||||
});
|
||||
|
||||
it('lists a single value on its own', () => {
|
||||
expect(
|
||||
describeSelection({ value: 'production', allSelected: false }),
|
||||
).toStrictEqual({ kind: 'values', values: ['production'] });
|
||||
});
|
||||
|
||||
it('reports a missing or empty selection as empty', () => {
|
||||
expect(describeSelection(undefined)).toStrictEqual({ kind: 'empty' });
|
||||
expect(describeSelection({ value: '', allSelected: false })).toStrictEqual({
|
||||
kind: 'empty',
|
||||
});
|
||||
expect(describeSelection({ value: [], allSelected: false })).toStrictEqual({
|
||||
kind: 'empty',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { VariableFetchState } from '../../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useFetchedVariableOptions } from '../hooks/useFetchedVariableOptions';
|
||||
|
||||
jest.mock('react-redux', () => ({ useSelector: jest.fn() }));
|
||||
|
||||
jest.mock('api/dynamicVariables/getFieldValues', () => ({
|
||||
getFieldValues: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseSelector = useSelector as unknown as jest.Mock;
|
||||
const mockGetFieldValues = getFieldValues as unknown as jest.Mock;
|
||||
|
||||
function fieldValues(values: string[]): unknown {
|
||||
return { data: { normalizedValues: values, complete: true } };
|
||||
}
|
||||
|
||||
/** A promise resolved by the test, so the in-flight window is deterministic. */
|
||||
function deferred(): {
|
||||
promise: Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
} {
|
||||
let settle: (value: unknown) => void = () => {};
|
||||
const promise = new Promise<unknown>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return { promise, resolve: settle };
|
||||
}
|
||||
|
||||
function dynamicVariable(name: string): VariableFormModel {
|
||||
return {
|
||||
...emptyVariableFormModel(),
|
||||
name,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'service.name',
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe('useFetchedVariableOptions', () => {
|
||||
beforeEach(() => {
|
||||
mockUseSelector.mockImplementation((selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
globalTime: {
|
||||
minTime: 1_000,
|
||||
maxTime: 2_000,
|
||||
isAutoRefreshDisabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetFieldValues.mockReset();
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableResolvedEmpty: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the previous options while a new fetch cycle is in flight', async () => {
|
||||
const refetch = deferred();
|
||||
mockGetFieldValues
|
||||
.mockResolvedValueOnce(fieldValues(['prod', 'staging']))
|
||||
.mockReturnValueOnce(refetch.promise);
|
||||
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: { env: VariableFetchState.Loading },
|
||||
variableCycleIds: { env: 1 },
|
||||
});
|
||||
|
||||
const variable = dynamicVariable('env');
|
||||
const { result } = renderHook(
|
||||
() => useFetchedVariableOptions(variable, [variable], {}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.options).toStrictEqual(['prod', 'staging']),
|
||||
);
|
||||
|
||||
// What a sibling dynamic's selection change does: bump the cycle id, which keys
|
||||
// a fresh request. The options must not blink empty in the meantime, or an ALL
|
||||
// selection (rendered from them) falls back to the placeholder.
|
||||
act(() => {
|
||||
useDashboardStore.setState({ variableCycleIds: { env: 2 } });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(true));
|
||||
expect(result.current.options).toStrictEqual(['prod', 'staging']);
|
||||
|
||||
await act(async () => {
|
||||
refetch.resolve(fieldValues(['prod']));
|
||||
await refetch.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.options).toStrictEqual(['prod']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
.tooltip {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
--typography-text-display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// A lone value, ALL, or the unset dash.
|
||||
.value {
|
||||
--typography-text-display: block;
|
||||
font-weight: var(--font-weight-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// One bullet per value when a variable holds several.
|
||||
.values {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
}
|
||||
|
||||
.valueItem {
|
||||
font-weight: var(--font-weight-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import TooltipScrollArea from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from '../../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../../selectionTypes';
|
||||
import SelectionValue from './SelectionValue';
|
||||
import styles from './HiddenVariablesTooltip.module.scss';
|
||||
|
||||
interface HiddenVariablesTooltipProps {
|
||||
/** The variables the collapsed bar isn't showing, in bar order. */
|
||||
variables: VariableFormModel[];
|
||||
selections: VariableSelectionMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the collapsed bar's `+N` tooltip: what each hidden variable is set to.
|
||||
* Name and value sit on their own lines — `$name` muted above its value, which
|
||||
* bullets out when there are several — so the two read apart at a glance instead of
|
||||
* running together as `name: value`.
|
||||
* Every hidden variable is listed; the box scrolls rather than truncating.
|
||||
*/
|
||||
function HiddenVariablesTooltip({
|
||||
variables,
|
||||
selections,
|
||||
}: HiddenVariablesTooltipProps): JSX.Element {
|
||||
return (
|
||||
<TooltipScrollArea>
|
||||
<div className={styles.tooltip} data-testid="hidden-variables-tooltip">
|
||||
{variables.map((variable) => (
|
||||
<div className={styles.row} key={variable.name}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.name}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<SelectionValue selection={selections[variable.name]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default HiddenVariablesTooltip;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { describeSelection } from '../../utils/selectionDisplay';
|
||||
import styles from './HiddenVariablesTooltip.module.scss';
|
||||
|
||||
interface SelectionValueProps {
|
||||
selection?: VariableSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable's current value as tooltip content: `ALL`, a dash when unset, the lone
|
||||
* value on its own, or — when it holds several — one bullet per value so the list
|
||||
* doesn't read as one run-on string.
|
||||
*/
|
||||
function SelectionValue({ selection }: SelectionValueProps): JSX.Element {
|
||||
const display = describeSelection(selection);
|
||||
|
||||
if (display.kind !== 'values') {
|
||||
return (
|
||||
<Typography.Text size="sm" className={styles.value}>
|
||||
{display.kind === 'all' ? 'ALL' : '—'}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (display.values.length === 1) {
|
||||
return (
|
||||
<Typography.Text size="sm" className={styles.value}>
|
||||
{display.values[0]}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.values}>
|
||||
{display.values.map((value) => (
|
||||
<Typography.Text asChild size="sm" className={styles.valueItem} key={value}>
|
||||
<li>{value}</li>
|
||||
</Typography.Text>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectionValue;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import TooltipScrollArea, {
|
||||
TOOLTIP_SCROLL_CONTENT_CLASS,
|
||||
} from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface OverflowValuesTooltipProps {
|
||||
/** The selected values the pill hides behind this `+N`. */
|
||||
values: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The multi-select's `+N` overflow item, revealing on hover the values it stands
|
||||
* for — one bullet each, all of them, scrolling rather than truncating. The
|
||||
* scrollbar stays put instead of auto-hiding, so a capped list reads as scrollable.
|
||||
*/
|
||||
function OverflowValuesTooltip({
|
||||
values,
|
||||
}: OverflowValuesTooltipProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
delayDuration={300}
|
||||
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
|
||||
title={
|
||||
<TooltipScrollArea>
|
||||
<ul className={styles.overflowValues}>
|
||||
{values.map((value) => (
|
||||
<li key={value}>{value}</li>
|
||||
))}
|
||||
</ul>
|
||||
</TooltipScrollArea>
|
||||
}
|
||||
>
|
||||
{/* rc-select copies this node into the overflow wrapper's `title`; an empty
|
||||
one keeps the browser's own "[object Object]" tooltip out of the way. */}
|
||||
<span title="">+{values.length}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverflowValuesTooltip;
|
||||
@@ -6,6 +6,7 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import OverflowValuesTooltip from './OverflowValuesTooltip';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
@@ -97,7 +98,13 @@ function ValueSelector({
|
||||
placeholder="Select value"
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
|
||||
maxTagPlaceholder={(omitted): JSX.Element => (
|
||||
<OverflowValuesTooltip
|
||||
values={omitted.map((item) =>
|
||||
typeof item.label === 'string' ? item.label : String(item.value ?? ''),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
// Offer ALL only once options load, else a concrete value reads as "all".
|
||||
enableAllSelection={showAllOption && options.length > 0}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
|
||||
@@ -89,6 +89,7 @@ export function useFetchedVariableOptions(
|
||||
enabled: variable.type === 'QUERY' && canFetch,
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime,
|
||||
keepPreviousData: true,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
@@ -124,6 +125,7 @@ export function useFetchedVariableOptions(
|
||||
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime,
|
||||
keepPreviousData: true,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
|
||||
/**
|
||||
* What a selection reads as in a tooltip: the ALL label, nothing at all, or the
|
||||
* concrete values. Never truncated — the tooltip scrolls instead.
|
||||
*/
|
||||
export type SelectionDisplay =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'values'; values: string[] };
|
||||
|
||||
/** Describes a selection for display. */
|
||||
export function describeSelection(
|
||||
selection: VariableSelection | undefined,
|
||||
): SelectionDisplay {
|
||||
if (!selection) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return { kind: 'all' };
|
||||
}
|
||||
|
||||
const { value } = selection;
|
||||
const values = (Array.isArray(value) ? value : [value])
|
||||
.filter((entry) => entry !== '' && entry !== null && entry !== undefined)
|
||||
.map(String);
|
||||
|
||||
return values.length === 0 ? { kind: 'empty' } : { kind: 'values', values };
|
||||
}
|
||||
@@ -387,15 +387,23 @@ describe('usePanelQuery', () => {
|
||||
expect(result.current.pagination?.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it('drives canNext from the response cursor, not the row count', () => {
|
||||
// Full page but no cursor → backend says these are the last rows.
|
||||
it('drives canNext from the cursor OR a full page (offset fallback for non-timestamp sorts)', () => {
|
||||
// Full page, no cursor → the backend's offset path (a non-timestamp sort skips the
|
||||
// window/cursor path), so a full page is the has-more signal.
|
||||
withResponse(rawResponse(25));
|
||||
const noCursor = renderHook(() =>
|
||||
const fullPage = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(noCursor.result.current.pagination?.canNext).toBe(false);
|
||||
expect(fullPage.result.current.pagination?.canNext).toBe(true);
|
||||
|
||||
// Cursor present (even on a partial page) → more rows.
|
||||
// Partial page, no cursor → the last page.
|
||||
withResponse(rawResponse(3));
|
||||
const partialPage = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(partialPage.result.current.pagination?.canNext).toBe(false);
|
||||
|
||||
// Cursor present (even on a partial page) → more rows (timestamp window path).
|
||||
withResponse(rawResponse(3, 'cursor-1'));
|
||||
const withCursor = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
|
||||
@@ -283,8 +283,10 @@ export function usePanelQuery({
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
// Paging handles for raw/list panels. The backend sets `nextCursor` iff the page filled,
|
||||
// so it's the authoritative has-more signal (there's no total count on the wire).
|
||||
// Paging handles for raw/list panels. The backend only emits `nextCursor` on the
|
||||
// timestamp-ordered window path (isWindowList); a non-timestamp sort falls back to plain
|
||||
// offset paging with no cursor. So treat a full page as a has-more signal too — matching the
|
||||
// offset heuristic the logs/traces explorers use (there's no total count on the wire).
|
||||
const pagination = useMemo<PanelPagination | undefined>(() => {
|
||||
if (!isPaginated) {
|
||||
return undefined;
|
||||
@@ -300,7 +302,7 @@ export function usePanelQuery({
|
||||
return {
|
||||
pageIndex: Math.floor(safeOffset / safePageSize),
|
||||
canPrev: safeOffset > 0,
|
||||
canNext: !!result?.nextCursor,
|
||||
canNext: !!result?.nextCursor || (result?.rows?.length ?? 0) >= safePageSize,
|
||||
goPrev,
|
||||
goNext,
|
||||
pageSize: safePageSize,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// small ones as data URIs — they ship in the bundle and render with no network
|
||||
// call. Logos are a large, JSON-only catalogue, so `?url` keeps them as emitted
|
||||
// files fetched (and cached) on demand rather than bloating the bundle.
|
||||
const iconModules = import.meta.glob('../../../assets/Icons/**/*.svg', {
|
||||
const iconModules = import.meta.glob('../../../assets/Icons/**/*.{svg,png}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
@@ -136,9 +136,8 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
ID: "ListServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "List services metadata",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider, without any account context.",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.ListServicesMetadataParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.GettableServicesMetadata),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -177,9 +176,8 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
ID: "GetService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "Get service",
|
||||
Description: "This endpoint gets a service for the specified cloud provider",
|
||||
Description: "This endpoint gets a service definition for the specified cloud provider, without any account context.",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.GetServiceParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.Service),
|
||||
ResponseContentType: "application/json",
|
||||
|
||||
@@ -2,329 +2,82 @@ package contextlinks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
tracesV3 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v3"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func PrepareLinksToTraces(start, end time.Time, filterItems []v3.FilterItem) string {
|
||||
|
||||
// Traces list view expects time in nanoseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixNano(),
|
||||
End: end.UnixNano(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
options := URLShareableOptions{
|
||||
MaxLines: 2,
|
||||
Format: "list",
|
||||
SelectColumns: tracesV3.TracesListViewDefaultSelectedColumns,
|
||||
}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
urlEncodedTimeRange := url.QueryEscape(string(period))
|
||||
|
||||
builderQuery := v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceTraces,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Filters: &v3.FilterSet{
|
||||
Items: filterItems,
|
||||
Operator: "AND",
|
||||
},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
OrderBy: []v3.OrderBy{
|
||||
{
|
||||
ColumnName: "timestamp",
|
||||
Order: "desc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{
|
||||
{BuilderQuery: builderQuery},
|
||||
},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
urlEncodedOptions := url.QueryEscape(string(optionsData))
|
||||
|
||||
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
|
||||
}
|
||||
|
||||
func PrepareLinksToLogs(start, end time.Time, filterItems []v3.FilterItem) string {
|
||||
|
||||
// Logs list view expects time in milliseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixMilli(),
|
||||
End: end.UnixMilli(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
options := URLShareableOptions{
|
||||
MaxLines: 2,
|
||||
Format: "list",
|
||||
SelectColumns: []v3.AttributeKey{},
|
||||
}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
urlEncodedTimeRange := url.QueryEscape(string(period))
|
||||
|
||||
builderQuery := v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceLogs,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Filters: &v3.FilterSet{
|
||||
Items: filterItems,
|
||||
Operator: "AND",
|
||||
},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
OrderBy: []v3.OrderBy{
|
||||
{
|
||||
ColumnName: "timestamp",
|
||||
Order: "desc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{
|
||||
{BuilderQuery: builderQuery},
|
||||
},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
urlEncodedOptions := url.QueryEscape(string(optionsData))
|
||||
|
||||
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
|
||||
}
|
||||
|
||||
// The following function is used to prepare the where clause for the query
|
||||
// `lbls` contains the key value pairs of the labels from the result of the query
|
||||
// We iterate over the where clause and replace the labels with the actual values
|
||||
// There are two cases:
|
||||
// 1. The label is present in the where clause
|
||||
// 2. The label is not present in the where clause
|
||||
//
|
||||
// Example for case 2:
|
||||
// Latency by serviceName without any filter
|
||||
// In this case, for each service with latency > threshold we send a notification
|
||||
// The expectation will be that clicking on the related traces for service A, will
|
||||
// take us to the traces page with the filter serviceName=A
|
||||
// So for all the missing labels in the where clause, we add them as key = value
|
||||
//
|
||||
// Example for case 1:
|
||||
// Severity text IN (WARN, ERROR)
|
||||
// In this case, the Severity text will appear in the `lbls` if it were part of the group
|
||||
// by clause, in which case we replace it with the actual value for the notification
|
||||
// i.e Severity text = WARN
|
||||
// If the Severity text is not part of the group by clause, then we add it as it is.
|
||||
func PrepareFilters(labels map[string]string, whereClauseItems []v3.FilterItem, groupByItems []v3.AttributeKey, keys map[string]v3.AttributeKey) []v3.FilterItem {
|
||||
filterItems := make([]v3.FilterItem, 0)
|
||||
|
||||
//delete predefined alert labels
|
||||
for _, label := range PredefinedAlertLabels {
|
||||
delete(labels, label)
|
||||
}
|
||||
|
||||
added := make(map[string]struct{})
|
||||
|
||||
for _, item := range whereClauseItems {
|
||||
exists := false
|
||||
for key, value := range labels {
|
||||
if item.Key.Key == key {
|
||||
// if the label is present in the where clause, replace it with key = value
|
||||
filterItems = append(filterItems, v3.FilterItem{
|
||||
Key: item.Key,
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: value,
|
||||
})
|
||||
exists = true
|
||||
added[key] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// if there is no label for the filter item, add it as it is
|
||||
filterItems = append(filterItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
// if there are labels which are not part of the where clause, but
|
||||
// exist in the result, then they could be part of the group by clause
|
||||
for key, value := range labels {
|
||||
if _, ok := added[key]; !ok {
|
||||
// start by taking the attribute key from the keys map, if not present, create a new one
|
||||
var attributeKey v3.AttributeKey
|
||||
var attrFound bool
|
||||
|
||||
// as of now this logic will only apply for logs
|
||||
for _, tKey := range utils.GenerateEnrichmentKeys(v3.AttributeKey{Key: key}) {
|
||||
if val, ok := keys[tKey]; ok {
|
||||
attributeKey = val
|
||||
attrFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// check if the attribute key is directly present, as of now this will always be false for logs
|
||||
// as for logs it will be satisfied in the condition above
|
||||
if !attrFound {
|
||||
attributeKey, attrFound = keys[key]
|
||||
}
|
||||
|
||||
// if the attribute key is not present, create a new one
|
||||
if !attrFound {
|
||||
attributeKey = v3.AttributeKey{Key: key}
|
||||
}
|
||||
|
||||
// if there is a group by item with the same key, use that instead
|
||||
for _, groupByItem := range groupByItems {
|
||||
if groupByItem.Key == key {
|
||||
attributeKey = groupByItem
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
filterItems = append(filterItems, v3.FilterItem{
|
||||
Key: attributeKey,
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return filterItems
|
||||
}
|
||||
|
||||
// PrepareParamsForTracesV5 returns the traces explorer query params for the
|
||||
// given range and filter; the traces explorer writes its time params in
|
||||
// nanoseconds.
|
||||
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
|
||||
|
||||
// Traces list view expects time in nanoseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixNano(),
|
||||
End: end.UnixNano(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
options := URLShareableOptions{}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
|
||||
linkQuery := LinkQuery{
|
||||
BuilderQuery: v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceTraces,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
},
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
}
|
||||
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{
|
||||
linkQuery,
|
||||
},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(string(data))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("compositeQuery", compositeQuery)
|
||||
params.Set("timeRange", string(period))
|
||||
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
|
||||
params.Set("endTime", strconv.FormatInt(tr.End, 10))
|
||||
params.Set("options", string(optionsData))
|
||||
return params
|
||||
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
|
||||
}
|
||||
|
||||
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
|
||||
// range and filter; the logs explorer writes its time params in milliseconds.
|
||||
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
|
||||
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
|
||||
}
|
||||
|
||||
// Logs list view expects time in milliseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixMilli(),
|
||||
End: end.UnixMilli(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
options := URLShareableOptions{}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
|
||||
linkQuery := LinkQuery{
|
||||
BuilderQuery: v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceLogs,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
},
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
}
|
||||
|
||||
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
|
||||
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
QueryType: "builder",
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{
|
||||
linkQuery,
|
||||
},
|
||||
QueryData: []LinkQuery{{
|
||||
DataSource: dataSource,
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
}},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(string(data))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("compositeQuery", compositeQuery)
|
||||
params.Set("timeRange", string(period))
|
||||
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
|
||||
params.Set("endTime", strconv.FormatInt(tr.End, 10))
|
||||
params.Set("options", string(optionsData))
|
||||
params.Set("compositeQuery", url.QueryEscape(string(data)))
|
||||
params.Set("startTime", strconv.FormatInt(start, 10))
|
||||
params.Set("endTime", strconv.FormatInt(end, 10))
|
||||
return params
|
||||
}
|
||||
|
||||
// BuilderQueryForSignal returns the filter expression and group-by keys of the
|
||||
// builder query for the given signal, or found=false when the composite query
|
||||
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
|
||||
// TODO(srikanthccv): re-visit this and support multiple queries.
|
||||
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
switch signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
|
||||
case telemetrytypes.SignalTraces:
|
||||
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
var q qbtypes.QueryBuilderQuery[T]
|
||||
found := false
|
||||
for _, query := range queries {
|
||||
if query.Type != qbtypes.QueryTypeBuilder {
|
||||
continue
|
||||
}
|
||||
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
|
||||
q = spec
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found || q.Signal != signal {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if q.Filter != nil {
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
return filterExpr, q.GroupBy, true
|
||||
}
|
||||
|
||||
63
pkg/contextlinks/links_test.go
Normal file
63
pkg/contextlinks/links_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package contextlinks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuilderQueryForSignal(t *testing.T) {
|
||||
logQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{Expression: "severity_text = 'ERROR'"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
},
|
||||
}
|
||||
traceQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}
|
||||
promQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypePromQL,
|
||||
Spec: qbtypes.PromQuery{Name: "C"},
|
||||
}
|
||||
|
||||
t.Run("logs query among mixed queries", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
|
||||
require.Len(t, groupBy, 1)
|
||||
assert.Equal(t, "service.name", groupBy[0].Name)
|
||||
})
|
||||
|
||||
t.Run("traces query without filter", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
|
||||
require.True(t, found)
|
||||
assert.Empty(t, filterExpr)
|
||||
assert.Empty(t, groupBy)
|
||||
})
|
||||
|
||||
t.Run("no builder query for signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("no builder queries at all", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("unsupported signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
@@ -1,29 +1,20 @@
|
||||
package contextlinks
|
||||
|
||||
import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
// TODO(srikanthccv): Fix the URL management.
|
||||
type URLShareableTimeRange struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
PageSize int64 `json:"pageSize"`
|
||||
}
|
||||
|
||||
type FilterExpression struct {
|
||||
Expression string `json:"expression,omitempty"`
|
||||
}
|
||||
|
||||
type Aggregation struct {
|
||||
Expression string `json:"expression,omitempty"`
|
||||
}
|
||||
|
||||
// LinkQuery carries the only fields the explorer pages read from a shared
|
||||
// link; the frontend fills in the rest of the query shape with defaults.
|
||||
type LinkQuery struct {
|
||||
v3.BuilderQuery
|
||||
Filter *FilterExpression `json:"filter,omitempty"`
|
||||
Aggregations []*Aggregation `json:"aggregations,omitempty"`
|
||||
DataSource string `json:"dataSource"`
|
||||
Filter *FilterExpression `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
type URLShareableBuilderQuery struct {
|
||||
@@ -36,10 +27,4 @@ type URLShareableCompositeQuery struct {
|
||||
Builder URLShareableBuilderQuery `json:"builder"`
|
||||
}
|
||||
|
||||
type URLShareableOptions struct {
|
||||
MaxLines int `json:"maxLines"`
|
||||
Format string `json:"format"`
|
||||
SelectColumns []v3.AttributeKey `json:"selectColumns"`
|
||||
}
|
||||
|
||||
var PredefinedAlertLabels = []string{ruletypes.LabelThresholdName, ruletypes.LabelSeverityName, ruletypes.LabelLastSeen}
|
||||
|
||||
@@ -251,23 +251,7 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
queryParams := new(cloudintegrationtypes.ListServicesMetadataParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
// check if integration account exists and is not removed.
|
||||
if !queryParams.CloudIntegrationID.IsZero() {
|
||||
_, err := handler.module.GetConnectedAccount(ctx, orgID, queryParams.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
services, err := handler.module.ListServicesMetadata(ctx, orgID, provider, queryParams.CloudIntegrationID)
|
||||
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.OrgID), provider, valuer.UUID{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -336,22 +320,7 @@ func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
queryParams := new(cloudintegrationtypes.GetServiceParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check if integration account exists and is not removed.
|
||||
if !queryParams.CloudIntegrationID.IsZero() {
|
||||
_, err := handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), queryParams.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, queryParams.CloudIntegrationID)
|
||||
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, valuer.UUID{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -113,6 +113,8 @@ func (h *handler) GetRuleHistoryTimeline(w http.ResponseWriter, r *http.Request)
|
||||
Labels: item.Labels.ToQBLabels(),
|
||||
Fingerprint: item.Fingerprint,
|
||||
Value: item.Value,
|
||||
RelatedTracesLink: item.RelatedTracesLink,
|
||||
RelatedLogsLink: item.RelatedLogsLink,
|
||||
})
|
||||
}
|
||||
resp.Total = timelineTotal
|
||||
|
||||
104
pkg/modules/rulestatehistory/implrulestatehistory/links.go
Normal file
104
pkg/modules/rulestatehistory/implrulestatehistory/links.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package implrulestatehistory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/contextlinks"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// relatedLinkBuilder builds logs/traces explorer links that carry the rule's
|
||||
// filter and the entry's labels, so a history entry can be opened in the
|
||||
// explorer with the context that produced it.
|
||||
type relatedLinkBuilder struct {
|
||||
alertType ruletypes.AlertType
|
||||
evaluation ruletypes.Evaluation
|
||||
filterExpr string
|
||||
groupBy []qbtypes.GroupByKey
|
||||
}
|
||||
|
||||
// relatedLinkBuilderForRule returns nil when the rule cannot be loaded or is
|
||||
// not a logs/traces rule; callers then leave the links empty.
|
||||
func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUID, ruleID string) *relatedLinkBuilder {
|
||||
id, err := valuer.NewUUID(ruleID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
storableRule, err := m.ruleStore.GetStoredRule(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(storableRule.Data), &rule); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if rule.AlertType != ruletypes.AlertTypeLogs && rule.AlertType != ruletypes.AlertTypeTraces {
|
||||
return nil
|
||||
}
|
||||
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
builder := &relatedLinkBuilder{alertType: rule.AlertType}
|
||||
if rule.Evaluation != nil {
|
||||
if evaluation, err := rule.Evaluation.GetEvaluation(); err == nil {
|
||||
builder.evaluation = evaluation
|
||||
}
|
||||
}
|
||||
if builder.evaluation == nil {
|
||||
evalWindow := rule.EvalWindow
|
||||
if evalWindow.IsZero() {
|
||||
evalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
|
||||
}
|
||||
|
||||
signal := telemetrytypes.SignalLogs
|
||||
if rule.AlertType == ruletypes.AlertTypeTraces {
|
||||
signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
// links are still built from the labels alone when the rule has no builder
|
||||
// query for the signal (e.g. ClickHouse SQL alerts)
|
||||
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
// queryWindow returns the range the rule evaluated when it recorded a state
|
||||
// change at unixMilli.
|
||||
// why are we subtracting 3 minutes?
|
||||
// the query range is calculated based on the rule's evaluation window and
|
||||
// evalDelay; alerts have 2 minutes delay built in, so we need to subtract
|
||||
// that from the start time to get the correct query range.
|
||||
func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time) {
|
||||
start, end := b.evaluation.NextWindowFor(time.Unix(unixMilli/1000, 0))
|
||||
return start.Add(-3 * time.Minute), end
|
||||
}
|
||||
|
||||
// links returns the encoded logs and traces explorer query params for the
|
||||
// given entry labels and time range; at most one of the two is non-empty.
|
||||
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) (string, string) {
|
||||
lbls := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
|
||||
|
||||
switch b.alertType {
|
||||
case ruletypes.AlertTypeLogs:
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode(), ""
|
||||
case ruletypes.AlertTypeTraces:
|
||||
return "", contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store rulestatehistorytypes.Store
|
||||
store rulestatehistorytypes.Store
|
||||
ruleStore ruletypes.RuleStore
|
||||
}
|
||||
|
||||
func NewModule(store rulestatehistorytypes.Store) rulestatehistory.Module {
|
||||
return &module{store: store}
|
||||
func NewModule(store rulestatehistorytypes.Store, ruleStore ruletypes.RuleStore) rulestatehistory.Module {
|
||||
return &module{store: store, ruleStore: ruleStore}
|
||||
}
|
||||
|
||||
func (m *module) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]rulestatehistorytypes.RuleStateHistory, error) {
|
||||
@@ -25,7 +26,19 @@ func (m *module) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error) {
|
||||
return m.store.ReadRuleStateHistoryByRuleID(ctx, orgID, ruleID, &query)
|
||||
items, total, err := m.store.ReadRuleStateHistoryByRuleID(ctx, orgID, ruleID, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
|
||||
for idx := range items {
|
||||
start, end := builder.queryWindow(items[idx].UnixMilli)
|
||||
items[idx].RelatedLogsLink, items[idx].RelatedTracesLink = builder.links(items[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryFilterKeys(ctx context.Context, orgID valuer.UUID, ruleID string, query rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldKeys, error) {
|
||||
@@ -37,7 +50,21 @@ func (m *module) GetHistoryFilterValues(ctx context.Context, orgID valuer.UUID,
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error) {
|
||||
return m.store.ReadRuleStateHistoryTopContributorsByRuleID(ctx, orgID, ruleID, &query)
|
||||
contributors, err := m.store.ReadRuleStateHistoryTopContributorsByRuleID(ctx, orgID, ruleID, &query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
|
||||
// contributor counts aggregate the whole selected range, so the links
|
||||
// span it too instead of a single evaluation window
|
||||
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
|
||||
for idx := range contributors {
|
||||
contributors[idx].RelatedLogsLink, contributors[idx].RelatedTracesLink = builder.links(contributors[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
return contributors, nil
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryOverallStatus(ctx context.Context, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.GettableRuleStateWindow, error) {
|
||||
|
||||
@@ -23,7 +23,8 @@ type Module interface {
|
||||
GetHistoryStats(context.Context, string, rulestatehistorytypes.Query) (rulestatehistorytypes.GettableRuleStateHistoryStats, error)
|
||||
|
||||
// GetHistoryTimeline returns a time-ordered list of rule state history entries and a total count
|
||||
// for the given query, suitable for paginated timeline views.
|
||||
// for the given query, suitable for paginated timeline views. For logs/traces rules, each entry
|
||||
// carries a related logs/traces explorer link scoped to the entry's labels and evaluation window.
|
||||
GetHistoryTimeline(context.Context, valuer.UUID, string, rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error)
|
||||
|
||||
// GetHistoryFilterKeys returns the available filter keys for rule state history queries.
|
||||
@@ -33,6 +34,8 @@ type Module interface {
|
||||
GetHistoryFilterValues(context.Context, valuer.UUID, string, string, rulestatehistorytypes.Query, string, int64) (*telemetrytypes.GettableFieldValues, error)
|
||||
|
||||
// GetHistoryContributors returns the top contributors to trigger alert, for the given query.
|
||||
// For logs/traces rules, each contributor carries a related logs/traces explorer link scoped
|
||||
// to the contributor's labels and the queried range.
|
||||
GetHistoryContributors(context.Context, valuer.UUID, string, rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error)
|
||||
|
||||
// GetHistoryOverallStatus returns the overall status windows for rule state history,
|
||||
|
||||
@@ -100,27 +100,12 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
return nil
|
||||
}
|
||||
|
||||
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
|
||||
for _, query := range r.ruleCondition.CompositeQuery.Queries {
|
||||
if query.Type == qbtypes.QueryTypeBuilder {
|
||||
switch spec := query.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
q = spec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q.Signal != telemetrytypes.SignalLogs {
|
||||
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalLogs)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if q.Filter != nil && q.Filter.Expression != "" {
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
|
||||
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
|
||||
}
|
||||
@@ -140,27 +125,12 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
|
||||
return nil
|
||||
}
|
||||
|
||||
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
|
||||
|
||||
for _, query := range r.ruleCondition.CompositeQuery.Queries {
|
||||
if query.Type == qbtypes.QueryTypeBuilder {
|
||||
switch spec := query.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
q = spec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q.Signal != telemetrytypes.SignalTraces {
|
||||
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalTraces)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if q.Filter != nil && q.Filter.Expression != "" {
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
|
||||
|
||||
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -195,10 +196,16 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
|
||||
ts := time.UnixMilli(1705469040000)
|
||||
|
||||
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{}).Encode()
|
||||
assert.Contains(t, params, "&timeRange=%7B%22start%22%3A1705468620000%2C%22end%22%3A1705468920000%2C%22pageSize%22%3A100%7D")
|
||||
assert.Contains(t, params, "&startTime=1705468620000")
|
||||
assert.Contains(t, params, "&endTime=1705468920000")
|
||||
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{})
|
||||
assert.Equal(t, "1705468620000", params.Get("startTime"))
|
||||
assert.Equal(t, "1705468920000", params.Get("endTime"))
|
||||
|
||||
// the frontend decodes compositeQuery twice, so it must be escaped once
|
||||
// before being encoded into the params
|
||||
assert.Contains(t, params.Encode(), "compositeQuery=%257B")
|
||||
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
|
||||
}
|
||||
|
||||
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
@@ -257,8 +264,13 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
|
||||
ts := time.UnixMilli(1753527163000)
|
||||
|
||||
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{}).Encode()
|
||||
assert.Contains(t, params, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522logs%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&endTime=1753527000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D&startTime=1753526700000&timeRange=%7B%22start%22%3A1753526700000%2C%22end%22%3A1753527000000%2C%22pageSize%22%3A100%7D")
|
||||
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{})
|
||||
assert.Equal(t, "1753526700000", params.Get("startTime"))
|
||||
assert.Equal(t, "1753527000000", params.Get("endTime"))
|
||||
|
||||
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
|
||||
}
|
||||
|
||||
func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
@@ -317,8 +329,13 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
|
||||
ts := time.UnixMilli(1753527163000)
|
||||
|
||||
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{}).Encode()
|
||||
assert.Contains(t, params, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522traces%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&endTime=1753527000000000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D&startTime=1753526700000000000&timeRange=%7B%22start%22%3A1753526700000000000%2C%22end%22%3A1753527000000000000%2C%22pageSize%22%3A100%7D")
|
||||
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{})
|
||||
assert.Equal(t, "1753526700000000000", params.Get("startTime"))
|
||||
assert.Equal(t, "1753527000000000000", params.Get("endTime"))
|
||||
|
||||
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"traces","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
|
||||
}
|
||||
|
||||
func TestPrepareParamsForTraces(t *testing.T) {
|
||||
@@ -375,10 +392,16 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
|
||||
ts := time.UnixMilli(1705469040000)
|
||||
|
||||
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{}).Encode()
|
||||
assert.Contains(t, params, "&timeRange=%7B%22start%22%3A1705468620000000000%2C%22end%22%3A1705468920000000000%2C%22pageSize%22%3A100%7D")
|
||||
assert.Contains(t, params, "&startTime=1705468620000000000")
|
||||
assert.Contains(t, params, "&endTime=1705468920000000000")
|
||||
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{})
|
||||
assert.Equal(t, "1705468620000000000", params.Get("startTime"))
|
||||
assert.Equal(t, "1705468920000000000", params.Get("endTime"))
|
||||
|
||||
// the frontend decodes compositeQuery twice, so it must be escaped once
|
||||
// before being encoded into the params
|
||||
assert.Contains(t, params.Encode(), "compositeQuery=%257B")
|
||||
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"traces","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
|
||||
}
|
||||
|
||||
func TestThresholdRuleLabelNormalization(t *testing.T) {
|
||||
|
||||
@@ -159,7 +159,7 @@ func NewModules(
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
|
||||
@@ -103,7 +103,7 @@ func (migration *addTelemetryTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "TUPLE_OPERATION_WRITE", tupleID, now,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2359,7 +2359,10 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
|
||||
if temporality != metrictypes.Unknown {
|
||||
temporalities[metricName] = append(temporalities[metricName], temporality)
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
// Monotonicity carries meaning only for cumulative sums: a non-monotonic
|
||||
// cumulative sum is a gauge for all practical purposes. Delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
types[metricName] = metricType
|
||||
@@ -2412,7 +2415,9 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
|
||||
if err := rows.Scan(&metricName, &temporality, &metricType, &isMonotonic); err != nil {
|
||||
return nil, nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to scan temporality result")
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
// Non-monotonic cumulative sums are treated as gauges; delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
temporalities[metricName] = temporality
|
||||
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -109,3 +111,85 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
|
||||
t.Errorf("there were unfulfilled expectations: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sum metrics are classified as counters unless they are non-monotonic AND
|
||||
// cumulative; monotonicity carries no meaning for delta sums, so a
|
||||
// non-monotonic delta sum must remain a Sum (counter).
|
||||
func TestFetchTemporalityAndTypeMultiSumClassification(t *testing.T) {
|
||||
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, ®exMatcher{})
|
||||
mock := mockTelemetryStore.Mock()
|
||||
|
||||
metadata := NewTelemetryMetaStore(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockTelemetryStore,
|
||||
telemetrytraces.DBName,
|
||||
telemetrytraces.TagAttributesV2TableName,
|
||||
telemetrytraces.SpanAttributesKeysTblName,
|
||||
telemetrytraces.SpanIndexV3TableName,
|
||||
telemetrymetrics.DBName,
|
||||
telemetrymetrics.AttributesMetadataTableName,
|
||||
telemetrymeter.DBName,
|
||||
telemetrymeter.SamplesAgg1dTableName,
|
||||
telemetrylogs.DBName,
|
||||
telemetrylogs.LogsV2TableName,
|
||||
telemetrylogs.TagAttributesV2TableName,
|
||||
telemetrylogs.LogAttributeKeysTblName,
|
||||
telemetrylogs.LogResourceKeysTblName,
|
||||
telemetryaudit.DBName,
|
||||
telemetryaudit.AuditLogsTableName,
|
||||
telemetryaudit.TagAttributesTableName,
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
DBName,
|
||||
AttributesMetadataTableName,
|
||||
ColumnEvolutionMetadataTableName,
|
||||
flaggertest.New(t),
|
||||
)
|
||||
|
||||
metricNames := []string{
|
||||
"delta.nonmono.sum",
|
||||
"delta.mono.sum",
|
||||
"cumulative.nonmono.sum",
|
||||
"cumulative.mono.sum",
|
||||
}
|
||||
|
||||
metadataCols := []cmock.ColumnType{
|
||||
{Name: "metric_name", Type: "String"},
|
||||
{Name: "temporality", Type: "String"},
|
||||
{Name: "type", Type: "String"},
|
||||
{Name: "is_monotonic", Type: "Bool"},
|
||||
}
|
||||
|
||||
mock.ExpectQuery(`SELECT metric_name, temporality, any\(type\) AS type, argMax\(is_monotonic, unix_milli\) as is_monotonic FROM signoz_metrics\.`).
|
||||
WithArgs(nil, nil, nil).
|
||||
WillReturnRows(cmock.NewRows(metadataCols, [][]any{
|
||||
{"delta.nonmono.sum", metrictypes.Delta, metrictypes.SumType, false},
|
||||
{"delta.mono.sum", metrictypes.Delta, metrictypes.SumType, true},
|
||||
{"cumulative.nonmono.sum", metrictypes.Cumulative, metrictypes.SumType, false},
|
||||
{"cumulative.mono.sum", metrictypes.Cumulative, metrictypes.SumType, true},
|
||||
}))
|
||||
mock.ExpectQuery(`SELECT metric_name, argMax\(temporality, unix_milli\) as temporality, any\(type\) AS type, argMax\(is_monotonic, unix_milli\) as is_monotonic FROM signoz_meter\.`).
|
||||
WithArgs(nil).
|
||||
WillReturnRows(cmock.NewRows(metadataCols, [][]any{}))
|
||||
|
||||
temporalities, types, _, err := metadata.FetchTemporalityAndTypeMulti(
|
||||
context.Background(),
|
||||
valuer.GenerateUUID(),
|
||||
1700000000000,
|
||||
1700003600000,
|
||||
metricNames...,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, metrictypes.SumType, types["delta.nonmono.sum"])
|
||||
assert.Equal(t, metrictypes.SumType, types["delta.mono.sum"])
|
||||
assert.Equal(t, metrictypes.GaugeType, types["cumulative.nonmono.sum"])
|
||||
assert.Equal(t, metrictypes.SumType, types["cumulative.mono.sum"])
|
||||
|
||||
assert.Equal(t, metrictypes.Delta, temporalities["delta.nonmono.sum"])
|
||||
assert.Equal(t, metrictypes.Cumulative, temporalities["cumulative.nonmono.sum"])
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("there were unfulfilled expectations: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,6 @@ type GettableServicesMetadata struct {
|
||||
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type ListServicesMetadataParams struct {
|
||||
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
|
||||
}
|
||||
|
||||
// Service represents a cloud integration service with its definition,
|
||||
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
|
||||
type Service struct {
|
||||
@@ -63,10 +59,6 @@ type ServiceAssets struct {
|
||||
Dashboards []*ServiceDashboard `json:"dashboards" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GetServiceParams struct {
|
||||
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
|
||||
}
|
||||
|
||||
type UpdatableService struct {
|
||||
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
@@ -180,6 +180,11 @@ func placedWidgetIDs(data StorableDashboardData) map[string]bool {
|
||||
for _, e := range layout {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
if i, ok := m["i"].(string); ok && i != "" {
|
||||
// A zero-width placement doesn't render in the v1 UI; treat it as
|
||||
// unplaced so the widget is dropped rather than migrated invisibly.
|
||||
if w, ok := m["w"].(float64); ok && w <= 0 {
|
||||
continue
|
||||
}
|
||||
ids[i] = true
|
||||
}
|
||||
}
|
||||
@@ -320,6 +325,9 @@ func compactGridItemsVertically(items []dashboard.GridItem) {
|
||||
if l.X < 0 {
|
||||
l.X = 0
|
||||
}
|
||||
if l.X+l.Width > gridColumnCount { // still overflows (wider than the grid) → clamp width so x+width = cols
|
||||
l.Width = gridColumnCount - l.X
|
||||
}
|
||||
if l.Y < 0 {
|
||||
l.Y = 0
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user