Compare commits

..

8 Commits

Author SHA1 Message Date
Gaurav Tewari
e7b74be67f Merge remote-tracking branch 'origin/main' into test/gcp-integration
# Conflicts:
#	tests/fixtures/cloudintegrations.py
#	tests/integration/tests/cloudintegrations/04_accounts.py
2026-07-27 10:23:03 +05:30
Gaurav Tewari
5d661217e9 test: merge feat/gcp-cloud-integration + fix/gcp-list-accounts for QA 2026-07-22 10:19:31 +05:30
Gaurav Tewari
4f3fe7ec6e fix(gcp): treat GCP as a one-click integration for connection status
isOneClickIntegration only listed AWS and Azure, so IntegrationDetailPage
treated GCP as a legacy integration and polled
GET /integrations/gcp/connection_status every 5s via useGetIntegrationStatus.
GCP is a one-click cloud integration like AWS/Azure and has no such legacy
status endpoint, so add it to the allowlist to disable the poll.
2026-07-22 09:53:35 +05:30
Gaurav Tewari
4f45b3a691 feat(gcp): add account settings (edit) drawer
Adds the GCP edit-account drawer for managing monitored project ids
and wires it into AccountActions so 'Edit Account' works for GCP.
2026-07-22 09:17:28 +05:30
Gaurav Tewari
dc4b4c3d03 feat(gcp): add cloud account setup (add-account) drawer
Adds the GCP add-account drawer (flow selector, connection secret
fields with shared CopyButton, validators) and wires it into
AccountActions so 'Integrate Now' / 'Add New Account' work for GCP.

Extends the shared periscope CopyButton with an optional onCopy
callback so callers can show a toast on copy.
2026-07-22 09:17:01 +05:30
Gaurav Tewari
c5f550d88f feat(gcp): add GCP integration config, types, and page wiring
Adds the GCP integration tile, config types, region constants, account
DTO mapping, and routes the 'gcp' integration id to the shared
CloudIntegration page. Accounts list, service details, and account
removal work for GCP; the add-account and edit-account drawers land in
follow-up commits (their buttons are no-ops for GCP in this commit).

Also narrows Azure config checks by 'resource_groups' instead of
'deployment_region', since the GCP config in the widened
CloudAccount.config union also has a deployment_region field.
2026-07-22 09:16:30 +05:30
swapnil-signoz
f5f8da4226 refactor: updating cloudintegration integration tests 2026-07-20 22:45:58 +05:30
swapnil-signoz
e8fae56354 fix: list gcp accounts was missing config 2026-07-20 20:07:21 +05:30
166 changed files with 2755 additions and 3730 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 128 128"><path fill="#ea4535" d="M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z"/><path fill="#557ebf" d="M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z"/><path fill="#36a852" d="M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z"/><path fill="#f9bc15" d="M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z"/></svg>

After

Width:  |  Height:  |  Size: 805 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,9 +17,12 @@ import { ChevronDown, Dot, PencilLine, Plug, Plus } from '@signozhq/icons';
import AzureCloudAccountSetupModal from '../../AzureCloudServices/AddNewAccount/CloudAccountSetupModal';
import AzureAccountSettingsModal from '../../AzureCloudServices/EditAccount/AccountSettingsModal';
import GcpCloudAccountSetupDrawer from '../../GoogleCloudPlatform/AddNewAccount/CloudAccountSetupDrawer';
import GcpAccountSettingsDrawer from '../../GoogleCloudPlatform/EditAccount/AccountSettingsDrawer';
import {
mapAccountDtoToAwsCloudAccount,
mapAccountDtoToAzureCloudAccount,
mapAccountDtoToGcpCloudAccount,
} from '../../mapCloudAccountFromDto';
import AwsCloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
import AwsAccountSettingsModal from '../EditAccount/AccountSettingsModal';
@@ -156,6 +159,18 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
});
}
if (type === IntegrationType.GCP_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToGcpCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
return mappedAccounts;
}, [listAccountsResponse, type]);
@@ -207,13 +222,23 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
// log telemetry event when an account is viewed.
useEffect(() => {
if (activeAccount) {
const { config } = activeAccount;
let enabledRegions: string[];
if ('regions' in config) {
// AWS
enabledRegions = config.regions;
} else if ('resource_groups' in config) {
// Azure
enabledRegions = config.resource_groups;
} else {
// GCP
enabledRegions = config.project_ids;
}
logEvent(`${type} Integration: Account viewed`, {
cloudAccountId: activeAccount?.cloud_account_id,
status: activeAccount?.status,
enabledRegions:
'regions' in activeAccount.config
? activeAccount.config.regions
: activeAccount.config.resource_groups,
enabledRegions,
});
}
}, [activeAccount, type]);
@@ -260,6 +285,11 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpCloudAccountSetupDrawer
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
</>
)}
@@ -281,6 +311,13 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
setActiveAccount={setActiveAccount}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpAccountSettingsDrawer
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</>
)}
</div>

View File

@@ -46,14 +46,31 @@ const EMPTY_FORM_VALUES: ServiceConfigFormValues = {
s3BucketsByRegion: {},
};
function getIntegrationServiceConfig(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
):
| { logs?: { enabled?: boolean }; metrics?: { enabled?: boolean } }
| undefined {
const config = serviceDetailsData?.cloudIntegrationService?.config;
if (type === IntegrationType.AWS_SERVICES) {
return config?.aws;
}
if (type === IntegrationType.GCP_SERVICES) {
return config?.gcp;
}
return config?.azure;
}
function getInitialFormValues(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
): ServiceConfigFormValues {
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
return {
logsEnabled: integrationConfig?.logs?.enabled || false,
@@ -98,16 +115,21 @@ function getServiceConfigPayload({
};
}
return {
azure: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
// Azure and GCP share the same simple logs/metrics enable-flag shape.
const signalConfig = {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
};
if (type === IntegrationType.GCP_SERVICES) {
return { gcp: signalConfig };
}
return { azure: signalConfig };
}
function ServiceDetails({
@@ -147,6 +169,7 @@ function ServiceDetails({
cloudProvider: type,
serviceId: serviceId || '',
},
undefined,
{
query: {
enabled: !!serviceId && !cloudAccountId,
@@ -162,10 +185,10 @@ function ServiceDetails({
? isAccountServiceLoading
: isReadOnlyServiceLoading;
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
const isServiceEnabledInPersistedConfig =
Boolean(integrationConfig?.logs?.enabled) ||
Boolean(integrationConfig?.metrics?.enabled);

View File

@@ -36,8 +36,12 @@ function AccountSettingsModal({
const queryClient = useQueryClient();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const azureConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
() => ('resource_groups' in account.config ? account.config : null),
[account.config],
);

View File

@@ -0,0 +1,150 @@
.setupDrawer {
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
// Fill every field with --l2-background (Input/ComboboxSimple default to transparent).
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--combobox-trigger-background-color: var(--l2-background);
// Text uses the brighter --l1-foreground, placeholders the duller --l3-foreground.
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
--combobox-trigger-placeholder-color: var(--l3-foreground);
// Shared resting border (--l2-border) and text size; focus borders stay per-component.
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--l2-border);
--combobox-trigger-border-color: var(--l2-border);
--input-font-size: var(--periscope-font-size-base);
--combobox-trigger-font-size: var(--periscope-font-size-base);
--combobox-trigger-value-font-size: var(--periscope-font-size-base);
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
padding: var(--spacing-10) var(--spacing-12);
overflow-y: auto;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
}
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.mono {
composes: mono from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.projectIdsSelect {
:global(.ant-select-selector) {
min-height: 36px;
background: var(--l2-background);
border: 1px solid var(--l2-border) !important;
}
&:hover :global(.ant-select-selector),
&:global(.ant-select-focused) :global(.ant-select-selector) {
border-color: var(--l2-border);
box-shadow: none;
}
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
:global(.ant-select-selection-placeholder),
:global(.ant-select-selection-search-input),
:global(.ant-select-selection-item) {
font-size: var(--periscope-font-size-base);
}
:global(.ant-select-selection-placeholder) {
color: var(--l3-foreground);
}
:global(.ant-select-selection-search-input) {
color: var(--l1-foreground);
}
:global(.ant-select-selection-item) {
color: var(--l1-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
:global(.ant-select-selection-item-remove) {
color: var(--l3-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}

View File

@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
import { Controller, useForm } from 'react-hook-form';
import { popupContainer } from 'utils/selectPopupContainer';
import ConnectionSecretsFields from './ConnectionSecretsFields';
import FieldLabel from './FieldLabel';
import FlowSelector from './FlowSelector';
import { GcpSetupFormValues, SetupFlow } from './types';
import styles from './CloudAccountSetupDrawer.module.scss';
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
const DEFAULT_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
function CloudAccountSetupDrawer({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
isLoading,
connectAccount,
handleClose,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useCloudAccountSetupDrawer({ onClose });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_VALUES,
});
const [flow, setFlow] = useState<SetupFlow>('manual');
// Pre-fill the deployment/ingestion fields with the fetched credentials.
useEffect(() => {
if (!connectionParams) {
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
const footer = (
<div className={styles.footerContainer}>
{submitError && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={clearSubmitError}
title="Failed to connect GCP account"
testId="gcp-connect-error"
>
{submitError}
</Callout>
)}
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={handleClose}
testId="gcp-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading}
disabled={isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</div>
</div>
);
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="base"
footer={footer}
drawerHeaderProps={{ className: styles.title }}
>
<FlowSelector value={flow} onChange={setFlow} />
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
/>
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
required
/>
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip="The GCP region where your OTel Collector will be deployed"
required
/>
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Select
id="gcp-project-ids-select"
className={cx(styles.fullWidth, styles.projectIdsSelect)}
mode="tags"
value={field.value}
onChange={(value): void => field.onChange(value)}
placeholder="Add project IDs…"
tokenSeparators={[',', ' ']}
notFoundContent={null}
suffixIcon={null}
getPopupContainer={popupContainer}
data-testid="gcp-project-ids-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</DrawerWrapper>
);
}
export default CloudAccountSetupDrawer;

View File

@@ -0,0 +1,71 @@
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.headLabel {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.mono {
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.secretsBody {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.skeletonLabel :global(.ant-skeleton-input) {
width: 120px;
min-width: 120px;
height: 14px;
}
.skeletonInput :global(.ant-skeleton-input) {
width: 100%;
min-width: 100%;
height: 36px;
}
.readonlyField {
display: flex;
gap: var(--spacing-2);
align-items: center;
height: 36px;
padding: 0 var(--spacing-2) 0 var(--spacing-4);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
.readonlyValue {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
// Match the other fields' value text: 13px and the brighter --l1-foreground.
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
cursor: not-allowed;
}

View File

@@ -0,0 +1,193 @@
import { Lock } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
type CredentialField = keyof CloudintegrationtypesCredentialsDTO;
interface FieldConfig {
name: CredentialField;
label: string;
tooltip: string;
placeholder: string;
testId: string;
type: SecretFieldType;
}
const FIELDS: FieldConfig[] = [
{
name: 'sigNozApiUrl',
label: 'SigNoz API URL',
tooltip: 'Base URL of your SigNoz instance the collector reports to',
placeholder: 'https://<tenant>.signoz.cloud',
testId: 'gcp-signoz-api-url-input',
type: 'url',
},
{
name: 'sigNozApiKey',
label: 'SigNoz API Key',
tooltip: 'API key used to authenticate with your SigNoz instance',
placeholder: 'Enter SigNoz API key',
testId: 'gcp-signoz-api-key-input',
type: 'text',
},
{
name: 'ingestionUrl',
label: 'Ingestion URL',
tooltip: 'OTLP ingestion endpoint your OTel Collector sends telemetry to',
placeholder: 'https://ingest.<region>.signoz.cloud',
testId: 'gcp-ingestion-url-input',
type: 'url',
},
{
name: 'ingestionKey',
label: 'Ingestion Key',
tooltip: 'Ingestion key that authorizes telemetry sent to SigNoz',
placeholder: 'Enter ingestion key',
testId: 'gcp-ingestion-key-input',
type: 'text',
},
];
interface ConnectionSecretsFieldsProps {
control: Control<GcpSetupFormValues>;
isLoading: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO;
}
function ConnectionSecretsFields({
control,
isLoading,
connectionParams,
}: ConnectionSecretsFieldsProps): JSX.Element {
const hasMissingValue = FIELDS.some(
(field) => !connectionParams?.[field.name],
);
return (
<div className={styles.drawerSurface}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Deployment details &amp; ingestion secrets
</Typography.Text>
{!hasMissingValue && (
<div className={styles.headLabel}>
<Lock size={12} />
<Typography.Text as="span" size="small" className={styles.headLabel}>
Auto-filled by SigNoz
</Typography.Text>
</div>
)}
</div>
{isLoading ? (
<div className={styles.secretsBody} data-testid="gcp-secrets-skeleton">
{FIELDS.map((field) => (
<div key={field.name} className={styles.drawerSection}>
<Skeleton.Input active size="small" className={styles.skeletonLabel} />
<Skeleton.Input active block className={styles.skeletonInput} />
</div>
))}
</div>
) : (
<div className={styles.secretsBody}>
{FIELDS.map((field) => {
// Backend-provided values are read-only — the user can't edit them, so
// show a truncated value with a copy button. Missing values (enterprise)
// stay editable inputs with no copy button.
const providedValue = connectionParams?.[field.name];
if (providedValue) {
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<div className={styles.readonlyField}>
<Typography.Text
as="span"
id={field.testId}
className={cx(styles.readonlyValue, styles.mono)}
title={providedValue}
testId={field.testId}
>
{providedValue}
</Typography.Text>
<CopyButton
value={providedValue}
size={12}
ariaLabel={`Copy ${field.label}`}
testId={`${field.testId}-copy`}
onCopy={(): void => {
toast.success(`${field.label} copied to clipboard`, {
position: 'bottom-right',
});
}}
/>
</div>
</div>
);
}
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<Controller
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
);
})}
</div>
)}
</div>
);
}
ConnectionSecretsFields.defaultProps = {
connectionParams: undefined,
};
export default ConnectionSecretsFields;

View File

@@ -0,0 +1,22 @@
.fieldLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.required {
composes: required from './shared.module.scss';
}
.infoTrigger {
display: inline-flex;
align-items: center;
color: var(--l3-foreground);
cursor: help;
}
.tooltipContent {
max-width: 240px;
white-space: normal;
word-break: break-word;
}

View File

@@ -0,0 +1,49 @@
import { Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './FieldLabel.module.scss';
interface FieldLabelProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
}
function FieldLabel({
htmlFor,
label,
tooltip,
required,
}: FieldLabelProps): JSX.Element {
return (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
<TooltipSimple
title={tooltip}
side="top"
tooltipContentProps={{ className: styles.tooltipContent }}
>
<span
className={styles.infoTrigger}
aria-label={`${label} help`}
data-testid={`${htmlFor}-tooltip`}
>
<Info size={12} />
</span>
</TooltipSimple>
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
</label>
);
}
FieldLabel.defaultProps = {
required: false,
};
export default FieldLabel;

View File

@@ -0,0 +1,84 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.flowRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
width: 100%;
padding: var(--spacing-5) var(--spacing-6);
margin: 0;
border: 1px solid transparent;
border-radius: var(--radius-2);
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&.flowRadioManual:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
&:has(button[disabled]) {
cursor: not-allowed;
opacity: 0.55;
&:hover {
background: var(--l3-background);
}
}
}
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -0,0 +1,76 @@
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { SetupFlow } from './types';
import styles from './FlowSelector.module.scss';
interface FlowSelectorProps {
value: SetupFlow;
onChange: (flow: SetupFlow) => void;
}
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Connection method
</Typography.Text>
</div>
<RadioGroup
value={value}
onChange={(next): void => onChange(next as SetupFlow)}
className={styles.flowRadioGroup}
>
<RadioGroupItem
value="manual"
containerClassName={cx(styles.flowRadio, styles.flowRadioManual)}
testId="gcp-flow-manual"
>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect Manually
</Typography.Text>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
Deploy your own OTel Collector and configure log sinks.
</Typography.Text>
</RadioGroupItem>
<RadioGroupItem
value="agent"
containerClassName={styles.flowRadio}
testId="gcp-flow-agent"
disabled
>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect via Agent
</Typography.Text>
<Badge color="robin" variant="default">
Soon
</Badge>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</RadioGroupItem>
</RadioGroup>
</div>
);
}
export default FlowSelector;

View File

@@ -0,0 +1,36 @@
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection > label {
font-size: var(--periscope-font-size-normal);
color: var(--l2-foreground);
}
.required {
color: var(--accent-cherry);
}
.fieldError {
font-size: var(--periscope-font-size-small);
color: var(--accent-cherry);
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.mono {
font-family: var(--font-family-mono);
}

View File

@@ -0,0 +1,12 @@
export type SetupFlow = 'manual' | 'agent';
export interface GcpSetupFormValues {
accountName: string;
deploymentProjectId: string;
deploymentRegion: string;
projectIds: string[];
sigNozApiUrl: string;
sigNozApiKey: string;
ingestionUrl: string;
ingestionKey: string;
}

View File

@@ -0,0 +1,24 @@
export type SecretFieldType = 'url' | 'text';
export function isValidUrl(value: string): boolean {
try {
return Boolean(new URL(value));
} catch {
return false;
}
}
export function validateSecretValue(
label: string,
type: SecretFieldType,
value: string | undefined,
): true | string {
const trimmed = value?.trim();
if (!trimmed) {
return `Please enter the ${label}`;
}
if (type === 'url' && !isValidUrl(trimmed)) {
return `Please enter a valid URL for ${label}`;
}
return true;
}

View File

@@ -0,0 +1,165 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Save } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Form, Select } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import '../../AmazonWebServices/EditAccount/AccountSettingsModal.style.scss';
interface AccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: AccountSettingsDrawerProps): JSX.Element {
const {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
} = useAccountSettingsDrawer({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const gcpConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
return (
<DrawerWrapper
open={true}
className="account-settings-modal"
title="Account Settings"
direction="right"
showCloseButton
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
width="wide"
footer={
<div className="account-settings-modal__footer">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={(): void => {
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
setActiveAccount(null);
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
/>
<Button
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
data-testid="gcp-update-account-btn"
>
Update Changes
</Button>
</div>
}
>
<Form
form={form}
layout="vertical"
initialValues={{
projectIds: gcpConfig?.project_ids || [],
}}
>
<div className="account-settings-modal__body">
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
Account Name:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.providerAccountId}
</span>
</div>
</div>
</div>
{gcpConfig?.deployment_project_id && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment project ID
</div>
<div className="account-settings-modal__body-region-selector-description">
{gcpConfig.deployment_project_id}
</div>
</div>
)}
{gcpConfig?.deployment_region && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment region
</div>
<div className="account-settings-modal__body-region-selector-description">
{gcpConfig.deployment_region}
</div>
</div>
)}
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Projects to monitor
</div>
<div className="account-settings-modal__body-region-selector-description">
Update the GCP project IDs that should be monitored.
</div>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<Select
mode="tags"
value={projectIds}
tokenSeparators={[',']}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
data-testid="gcp-edit-project-ids-select"
/>
</Form.Item>
</div>
</div>
</Form>
</DrawerWrapper>
);
}
export default AccountSettingsDrawer;

View File

@@ -60,6 +60,44 @@ function RemoveIntegrationAccount({
setIsModalOpen(false);
};
let modalDescription: JSX.Element;
if (cloudProvider === INTEGRATION_TYPES.AWS) {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
);
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
modalDescription = (
<>
Removing this account will stop SigNoz from monitoring it. <br />
<br />
Since you manage the GCP resources yourself, remember to manually tear down
the OTel collector and Pub/Sub resources you created for this integration if
you no longer need them.
</>
);
} else {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
);
}
return (
<div className="remove-integration-account-container">
<Button
@@ -84,28 +122,7 @@ function RemoveIntegrationAccount({
loading: isRemoveIntegrationLoading,
}}
>
{cloudProvider === INTEGRATION_TYPES.AWS ? (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
) : (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
)}
{modalDescription}
</Modal>
</div>
);

View File

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

View File

@@ -47,3 +47,27 @@ export function mapAccountDtoToAzureCloudAccount(
providerAccountId: account.providerAccountId,
};
}
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
project_ids: account.config?.gcp?.projectIds ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -13,8 +13,8 @@ import { ArrowLeft, MoveUpRight, RotateCw } from '@signozhq/icons';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import CloudIntegration from '../CloudIntegration/CloudIntegration';
import { INTEGRATION_TYPES } from '../constants';
import { IntegrationType } from '../types';
import { INTEGRATION_TYPES } from '../constants';
import { handleContactSupport } from '../utils';
import IntegrationDetailContent from './IntegrationDetailContent';
import IntegrationDetailHeader from './IntegrationDetailHeader';
@@ -24,6 +24,12 @@ import { getConnectionStatesFromConnectionStatus } from './utils';
import './IntegrationDetailPage.styles.scss';
const cloudIntegrationTypeById: Record<string, IntegrationType> = {
[INTEGRATION_TYPES.AWS]: IntegrationType.AWS_SERVICES,
[INTEGRATION_TYPES.AZURE]: IntegrationType.AZURE_SERVICES,
[INTEGRATION_TYPES.GCP]: IntegrationType.GCP_SERVICES,
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function IntegrationDetailPage(): JSX.Element {
const history = useHistory();
@@ -55,19 +61,8 @@ function IntegrationDetailPage(): JSX.Element {
),
);
if (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
) {
return (
<CloudIntegration
type={
integrationId === INTEGRATION_TYPES.AWS
? IntegrationType.AWS_SERVICES
: IntegrationType.AZURE_SERVICES
}
/>
);
if (integrationId && cloudIntegrationTypeById[integrationId]) {
return <CloudIntegration type={cloudIntegrationTypeById[integrationId]} />;
}
return (

View File

@@ -1,7 +1,8 @@
import awsDarkLogo from '@/assets/Logos/aws-dark.svg';
import azureOpenaiLogo from '@/assets/Logos/azure-openai.svg';
import gcpLogo from '@/assets/Logos/gcp.svg';
import { AzureRegion } from './types';
import { AzureRegion, GCPRegion } from './types';
export const INTEGRATION_TELEMETRY_EVENTS = {
INTEGRATIONS_LIST_VISITED: 'Integrations Page: Visited the list page',
@@ -21,6 +22,7 @@ export const INTEGRATION_TELEMETRY_EVENTS = {
export const INTEGRATION_TYPES = {
AWS: 'aws',
AZURE: 'azure',
GCP: 'gcp',
};
export const AWS_INTEGRATION = {
@@ -53,7 +55,26 @@ export const AZURE_INTEGRATION = {
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION, AZURE_INTEGRATION];
export const GCP_INTEGRATION = {
id: INTEGRATION_TYPES.GCP,
title: 'Google Cloud Platform',
description: 'Setup for GCP monitoring with SigNoz',
author: {
name: 'SigNoz',
email: 'integrations@signoz.io',
homepage: 'https://signoz.io',
},
icon: gcpLogo,
icon_alt: 'gcp-logo',
is_installed: false,
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [
AWS_INTEGRATION,
AZURE_INTEGRATION,
GCP_INTEGRATION,
];
export const AZURE_REGIONS: AzureRegion[] = [
{
@@ -165,3 +186,66 @@ export const AZURE_REGIONS: AzureRegion[] = [
{ label: 'West US 2', value: 'westus2', geography: 'United States' },
{ label: 'West US 3', value: 'westus3', geography: 'United States' },
];
// Source of truth: pkg/types/cloudintegrationtypes/regions.go (GCP regions).
export const GCP_REGIONS: GCPRegion[] = [
{ label: 'Johannesburg', value: 'africa-south1', geography: 'Africa' },
{ label: 'Changhua County', value: 'asia-east1', geography: 'APAC' },
{ label: 'Hong Kong', value: 'asia-east2', geography: 'APAC' },
{ label: 'Tokyo', value: 'asia-northeast1', geography: 'APAC' },
{ label: 'Osaka', value: 'asia-northeast2', geography: 'APAC' },
{ label: 'Seoul', value: 'asia-northeast3', geography: 'APAC' },
{ label: 'Mumbai', value: 'asia-south1', geography: 'APAC' },
{ label: 'Delhi', value: 'asia-south2', geography: 'APAC' },
{ label: 'Singapore', value: 'asia-southeast1', geography: 'APAC' },
{ label: 'Jakarta', value: 'asia-southeast2', geography: 'APAC' },
{ label: 'Bangkok', value: 'asia-southeast3', geography: 'APAC' },
{ label: 'Sydney', value: 'australia-southeast1', geography: 'APAC' },
{ label: 'Melbourne', value: 'australia-southeast2', geography: 'APAC' },
{ label: 'Warsaw', value: 'europe-central2', geography: 'Europe' },
{ label: 'Hamina', value: 'europe-north1', geography: 'Europe' },
{ label: 'Stockholm', value: 'europe-north2', geography: 'Europe' },
{ label: 'Madrid', value: 'europe-southwest1', geography: 'Europe' },
{ label: 'St. Ghislain', value: 'europe-west1', geography: 'Europe' },
{ label: 'London', value: 'europe-west2', geography: 'Europe' },
{ label: 'Frankfurt', value: 'europe-west3', geography: 'Europe' },
{ label: 'Eemshaven', value: 'europe-west4', geography: 'Europe' },
{ label: 'Zurich', value: 'europe-west6', geography: 'Europe' },
{ label: 'Milan', value: 'europe-west8', geography: 'Europe' },
{ label: 'Paris', value: 'europe-west9', geography: 'Europe' },
{ label: 'Berlin', value: 'europe-west10', geography: 'Europe' },
{ label: 'Turin', value: 'europe-west12', geography: 'Europe' },
{ label: 'Doha', value: 'me-central1', geography: 'Middle East' },
{ label: 'Dammam', value: 'me-central2', geography: 'Middle East' },
{ label: 'Tel Aviv', value: 'me-west1', geography: 'Middle East' },
{
label: 'Montréal',
value: 'northamerica-northeast1',
geography: 'North America',
},
{
label: 'Toronto',
value: 'northamerica-northeast2',
geography: 'North America',
},
{
label: 'Querétaro',
value: 'northamerica-south1',
geography: 'North America',
},
{
label: 'São Paulo',
value: 'southamerica-east1',
geography: 'South America',
},
{ label: 'Santiago', value: 'southamerica-west1', geography: 'South America' },
{ label: 'Council Bluffs', value: 'us-central1', geography: 'North America' },
{ label: 'Moncks Corner', value: 'us-east1', geography: 'North America' },
{ label: 'Ashburn', value: 'us-east4', geography: 'North America' },
{ label: 'Columbus', value: 'us-east5', geography: 'North America' },
{ label: 'Dallas', value: 'us-south1', geography: 'North America' },
{ label: 'The Dalles', value: 'us-west1', geography: 'North America' },
{ label: 'Los Angeles', value: 'us-west2', geography: 'North America' },
{ label: 'Salt Lake City', value: 'us-west3', geography: 'North America' },
{ label: 'Las Vegas', value: 'us-west4', geography: 'North America' },
];

View File

@@ -6,6 +6,7 @@ import {
export enum IntegrationType {
AWS_SERVICES = 'aws',
AZURE_SERVICES = 'azure',
GCP_SERVICES = 'gcp',
}
interface LogField {
@@ -87,7 +88,10 @@ export interface ServiceData {
export interface CloudAccount {
id: string;
cloud_account_id: string;
config: AzureCloudAccountConfig | AWSCloudAccountConfig;
config:
| AzureCloudAccountConfig
| AWSCloudAccountConfig
| GCPCloudAccountConfig;
status: AccountStatus | IServiceStatus;
providerAccountId: string;
}
@@ -97,6 +101,12 @@ export interface AzureCloudAccountConfig {
resource_groups: string[];
}
export interface GCPCloudAccountConfig {
deployment_region: string;
deployment_project_id: string;
project_ids: string[];
}
export interface AccountStatus {
integration: IntegrationStatus;
}
@@ -111,6 +121,12 @@ export interface AzureRegion {
value: string;
}
export interface GCPRegion {
label: string;
geography: string;
value: string;
}
export interface UpdateServiceConfigPayload {
cloud_account_id: string;
config: AzureServicesConfig;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ import {
export function isOneClickIntegration(integrationId: string): boolean {
return (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
integrationId === INTEGRATION_TYPES.AZURE ||
integrationId === INTEGRATION_TYPES.GCP
);
}

View File

@@ -39,8 +39,12 @@ export function useAccountSettingsModal({
}: UseAccountSettingsModalProps): UseAccountSettingsModal {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const accountConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
() => ('resource_groups' in account.config ? account.config : null),
[account.config],
);
const [resourceGroups, setResourceGroups] = useState<string[]>(

View File

@@ -0,0 +1,148 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Form } from 'antd';
import { FormInstance } from 'antd/lib';
import { useUpdateAccount } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
interface UseAccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
interface UseAccountSettingsDrawer {
form: FormInstance;
isLoading: boolean;
projectIds: string[];
isSaveDisabled: boolean;
setProjectIds: Dispatch<SetStateAction<string[]>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
export function useAccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: UseAccountSettingsDrawerProps): UseAccountSettingsDrawer {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
const accountConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
const [projectIds, setProjectIds] = useState<string[]>(
accountConfig?.project_ids || [],
);
useEffect(() => {
if (!accountConfig) {
return;
}
form.setFieldsValue({
projectIds: accountConfig.project_ids,
});
setProjectIds(accountConfig.project_ids);
}, [accountConfig, form]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
if (!accountConfig) {
return;
}
updateAccount(
{
pathParams: {
cloudProvider: INTEGRATION_TYPES.GCP,
id: account?.id || '',
},
data: {
config: {
gcp: {
// Deployment region & project ID are immutable in the UI, but the
// Updatable GCP DTO requires all three fields to be sent.
deploymentRegion: accountConfig.deployment_region,
deploymentProjectId: accountConfig.deployment_project_id,
projectIds: values.projectIds || [],
},
},
},
},
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig.deployment_region,
deployment_project_id: accountConfig.deployment_project_id,
project_ids: values.projectIds || [],
};
setActiveAccount({
...account,
config: nextConfig,
});
onClose();
toast.success('Account settings updated successfully', {
position: 'bottom-right',
});
void logEvent('GCP Integration: Account settings updated', {
cloudAccountId: account.cloud_account_id,
deploymentRegion: nextConfig.deployment_region,
projectIds: nextConfig.project_ids,
});
},
onError: (error) => {
toast.error('Failed to update account settings', {
description: error?.message,
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
}
}, [form, updateAccount, account, accountConfig, setActiveAccount, onClose]);
const isSaveDisabled = useMemo(() => {
if (!accountConfig) {
return true;
}
return isEqual(
[...(projectIds || [])].sort(),
[...accountConfig.project_ids].sort(),
);
}, [accountConfig, projectIds]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
return {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
};
}

View File

@@ -0,0 +1,165 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useAgentCheckIn,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import useAxiosError from 'hooks/useAxiosError';
import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useCloudAccountSetupDrawer({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const clearSubmitError = useCallback((): void => {
setSubmitError(null);
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
useGetConnectionCredentials<GetConnectionCredentialsQueryResult>(
{
cloudProvider: INTEGRATION_TYPES.GCP,
},
{
query: {
onError: handleError,
},
},
);
const handleClose = useCallback((): void => {
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: {
cloudIntegrationId: string;
providerAccountId: string;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId: payload.cloudIntegrationId,
providerAccountId: payload.providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
handleClose();
},
[handleClose, queryClient],
);
const connectAccount = useCallback(
async (values: GcpSetupFormValues): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
gcp: {
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
},
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
// their own (validated non-empty), so their form values are used.
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
ingestionKey: connectionParams?.data?.ingestionKey || values.ingestionKey,
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
// Step 1: create the integration account.
const createResponse: CreateAccountMutationResult = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: payload,
});
const cloudIntegrationId = createResponse.data.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
} catch (error) {
// Surface the backend's message inline in the drawer instead of a
// generic failure string.
const message = toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to connect GCP account',
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ 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';
@@ -68,11 +67,6 @@ function Header({
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback={false}
/>
{showSwitchToView && (
<Button
variant="outlined"

View File

@@ -1,69 +0,0 @@
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();
});
});

View File

@@ -174,13 +174,11 @@ const baseProps = {
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
draftOverrides?: { isSpecDirty?: boolean; draft?: DashboardtypesPanelDTO },
draftOverrides?: { isSpecDirty?: boolean },
): void {
// The live draft can diverge from the seed `panel`; default to the seed.
const draftPanel = draftOverrides?.draft ?? panel;
mockUseDraft.mockReturnValue({
draft: draftPanel,
spec: draftPanel.spec,
draft: panel,
spec: panel.spec,
setSpec: mockSetSpec,
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
});
@@ -273,23 +271,6 @@ 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' } } },

View File

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

View File

@@ -159,12 +159,11 @@ function PanelEditorContainer({
return section?.controls;
}, [panelDefinition]);
// 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.
// A new panel is savable once it has a query to run — List auto-seeds one; other
// kinds open query-less, so there's nothing to save until the user builds one.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';

View File

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

View File

@@ -83,32 +83,6 @@ 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(
[

View File

@@ -14,16 +14,15 @@ import type {
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Antenna,
BarChart,
Columns3,
Hash,
Link2,
Layers,
LayoutDashboard,
Link,
Palette,
PencilRuler,
Scale3D,
Signpost,
Wallpaper,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
// Derived from an actual icon component so the type stays exact (size is a
@@ -158,14 +157,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: PencilRuler },
[SectionKind.Axes]: { title: 'Axes', icon: Scale3D },
[SectionKind.Legend]: { title: 'Legend', icon: Signpost },
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: Hash },
[SectionKind.Axes]: { title: 'Axes', icon: Ruler },
[SectionKind.Legend]: { title: 'Legend', icon: Layers },
[SectionKind.ChartAppearance]: { title: 'Chart appearance', icon: Palette },
[SectionKind.Visualization]: { title: 'Visualization', icon: Wallpaper },
[SectionKind.Visualization]: { title: 'Visualization', icon: LayoutDashboard },
[SectionKind.Buckets]: { title: 'Histogram / Buckets', icon: BarChart },
[SectionKind.Thresholds]: { title: 'Thresholds', icon: Antenna },
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link2 },
[SectionKind.Thresholds]: { title: 'Thresholds', icon: SlidersHorizontal },
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link },
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
} as const satisfies Record<SectionKind, SectionMetadata>;

View File

@@ -67,16 +67,6 @@
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 {
@@ -138,3 +128,13 @@
min-width: 0 !important;
}
}
.overflowTooltip {
display: flex;
flex-direction: column;
gap: 2px;
}
.overflowName {
font-weight: var(--font-weight-medium);
}

View File

@@ -9,13 +9,28 @@ import { selectVariablesExpanded } from '../store/slices/collapseSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import AddVariableFull from './components/AddVariable/AddVariableFull';
import AddVariableIcon from './components/AddVariable/AddVariableIcon';
import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/TooltipScrollArea';
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import type { VariableSelection } from './selectionTypes';
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;
}
@@ -115,12 +130,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
) : (
<TooltipSimple
side="top"
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
title={
<HiddenVariablesTooltip
variables={hiddenVariables}
selections={selection}
/>
<div className={styles.overflowTooltip}>
{hiddenVariables.map((variable) => (
<div key={variable.name}>
<span className={styles.overflowName}>{variable.name}</span>:{' '}
{formatSelection(selection[variable.name])}
</div>
))}
</div>
}
>
{moreButton}

View File

@@ -1,84 +0,0 @@
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);
});
});

View File

@@ -1,90 +0,0 @@
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();
});
});

View File

@@ -1,39 +0,0 @@
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',
});
});
});

View File

@@ -1,117 +0,0 @@
// 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']));
});
});

View File

@@ -1,39 +0,0 @@
.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;
}

View File

@@ -1,42 +0,0 @@
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;

View File

@@ -1,46 +0,0 @@
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;

View File

@@ -1,43 +0,0 @@
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;

View File

@@ -6,7 +6,6 @@ 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 {
@@ -98,13 +97,7 @@ function ValueSelector({
placeholder="Select value"
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
<OverflowValuesTooltip
values={omitted.map((item) =>
typeof item.label === 'string' ? item.label : String(item.value ?? ''),
)}
/>
)}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
// Offer ALL only once options load, else a concrete value reads as "all".
enableAllSelection={showAllOption && options.length > 0}
onDropdownVisibleChange={(open): void => {

View File

@@ -89,7 +89,6 @@ export function useFetchedVariableOptions(
enabled: variable.type === 'QUERY' && canFetch,
refetchOnWindowFocus: false,
cacheTime,
keepPreviousData: true,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
@@ -125,7 +124,6 @@ export function useFetchedVariableOptions(
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
refetchOnWindowFocus: false,
cacheTime,
keepPreviousData: true,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)

View File

@@ -1,29 +0,0 @@
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 };
}

View File

@@ -387,23 +387,15 @@ describe('usePanelQuery', () => {
expect(result.current.pagination?.canNext).toBe(false);
});
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.
it('drives canNext from the response cursor, not the row count', () => {
// Full page but no cursor → backend says these are the last rows.
withResponse(rawResponse(25));
const fullPage = renderHook(() =>
const noCursor = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(fullPage.result.current.pagination?.canNext).toBe(true);
expect(noCursor.result.current.pagination?.canNext).toBe(false);
// 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).
// Cursor present (even on a partial page) → more rows.
withResponse(rawResponse(3, 'cursor-1'));
const withCursor = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),

View File

@@ -283,10 +283,8 @@ export function usePanelQuery({
[pageSize],
);
// 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).
// 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).
const pagination = useMemo<PanelPagination | undefined>(() => {
if (!isPaginated) {
return undefined;
@@ -302,7 +300,7 @@ export function usePanelQuery({
return {
pageIndex: Math.floor(safeOffset / safePageSize),
canPrev: safeOffset > 0,
canNext: !!result?.nextCursor || (result?.rows?.length ?? 0) >= safePageSize,
canNext: !!result?.nextCursor,
goPrev,
goNext,
pageSize: safePageSize,

View File

@@ -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,png}', {
const iconModules = import.meta.glob('../../../assets/Icons/**/*.svg', {
eager: true,
import: 'default',
});

View File

@@ -16,6 +16,8 @@ export interface CopyButtonProps {
/** Extra class merged onto the button. */
className?: string;
testId?: string;
/** Called after the copy is triggered (e.g. to show a toast). */
onCopy?: () => void;
}
/**
@@ -29,12 +31,14 @@ function CopyButton({
ariaLabel = 'Copy',
className,
testId,
onCopy,
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const handleClick = useCallback((): void => {
copyToClipboard(value);
}, [copyToClipboard, value]);
onCopy?.();
}, [copyToClipboard, value, onCopy]);
const stackStyle: CSSProperties = { width: size, height: size };
@@ -61,6 +65,7 @@ CopyButton.defaultProps = {
ariaLabel: 'Copy',
className: undefined,
testId: undefined,
onCopy: undefined,
};
export default CopyButton;

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