Compare commits

..

7 Commits

Author SHA1 Message Date
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
124 changed files with 2618 additions and 2349 deletions

View File

@@ -8558,7 +8558,6 @@ components:
- resource
- attribute
- body
- ""
type: string
TelemetrytypesFieldDataType:
enum:
@@ -8567,7 +8566,6 @@ components:
- float64
- int64
- number
- ""
type: string
TelemetrytypesGettableFieldKeys:
properties:
@@ -8599,7 +8597,6 @@ components:
- traces
- logs
- metrics
- ""
type: string
TelemetrytypesSource:
enum:

View File

@@ -223,12 +223,17 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
}
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {

View File

@@ -240,17 +240,17 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
mock := mockStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
// args: $1=metric_name (the __name__ matcher maps onto the column)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
// args: metric_name IN (discovered names), subquery metric_name, start, end
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).

View File

@@ -3479,7 +3479,6 @@ export enum TelemetrytypesFieldContextDTO {
resource = 'resource',
attribute = 'attribute',
body = 'body',
'' = '',
}
export enum TelemetrytypesFieldDataTypeDTO {
string = 'string',
@@ -3487,13 +3486,11 @@ export enum TelemetrytypesFieldDataTypeDTO {
float64 = 'float64',
int64 = 'int64',
number = 'number',
'' = '',
}
export enum TelemetrytypesSignalDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
'' = '',
}
export interface Querybuildertypesv5GroupByKeyDTO {
/**

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

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

@@ -20,7 +20,7 @@ import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
import ShareURLModal, { type ShareURLExtraOption } from './ShareURLModal';
import ShareURLModal from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { Typography } from '@signozhq/ui/typography';
@@ -29,15 +29,12 @@ interface HeaderRightSectionProps {
enableAnnouncements: boolean;
enableShare: boolean;
enableFeedback: boolean;
/** Optional page-specific toggle for the share dialog (e.g. "Include variables"). */
shareModalExtraOption?: ShareURLExtraOption;
}
function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
shareModalExtraOption,
}: HeaderRightSectionProps): JSX.Element | null {
const location = useLocation();
@@ -188,7 +185,7 @@ function HeaderRightSection({
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<ShareURLModal extraOption={shareModalExtraOption} />}
content={<ShareURLModal />}
open={openShareURLModal}
destroyTooltipOnHide
arrow={false}

View File

@@ -24,22 +24,7 @@ const routesToBeSharedWithTime = [
ROUTES.METER_EXPLORER,
];
/**
* An optional, page-specific toggle in the share dialog (e.g. a dashboard's
* "Include variables"). When enabled, `apply` mutates the URL params that go into
* the shared link. Keeps this shared modal generic — the page owns what it adds.
*/
export interface ShareURLExtraOption {
label: string;
defaultEnabled?: boolean;
apply: (params: URLSearchParams) => void;
}
interface ShareURLModalProps {
extraOption?: ShareURLExtraOption;
}
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
function ShareURLModal(): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
@@ -49,9 +34,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
selectedTime !== 'custom',
);
const [enableExtraOption, setEnableExtraOption] = useState(
extraOption?.defaultEnabled ?? false,
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
@@ -111,11 +93,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
}
}
if (extraOption && enableExtraOption) {
extraOption.apply(urlQuery);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
return currentUrl;
};
@@ -166,20 +143,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
</>
)}
{extraOption && (
<div className="absolute-relative-time-toggler-container">
<Typography.Text className="absolute-relative-time-toggler-label">
{extraOption.label}
</Typography.Text>
<div className="absolute-relative-time-toggler">
<Switch
value={enableExtraOption}
onChange={(): void => setEnableExtraOption((prev) => !prev)}
/>
</div>
</div>
)}
<div className="share-link">
<div className="url-share-container">
<div className="url-share-container-header">

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({
@@ -163,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

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

@@ -5262,24 +5262,6 @@ const onboardingConfigWithLinks = [
],
},
},
{
dataSource: 'temporal-cloud-metrics',
label: 'Temporal Cloud Metrics',
imgUrl: temporalUrl,
tags: ['metrics'],
module: 'metrics',
relatedSearchKeywords: [
'metrics',
'integrations',
'temporal',
'temporal cloud',
'temporal cloud metrics',
'temporal metrics',
'openmetrics',
'prometheus',
],
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
dataSource: 'temporal',
label: 'Temporal',
@@ -5291,6 +5273,9 @@ const onboardingConfigWithLinks = [
'application performance monitoring',
'integrations',
'temporal',
'temporal cloud',
'temporal logs',
'temporal metrics',
'temporal traces',
'traces',
'tracing',
@@ -5299,6 +5284,12 @@ const onboardingConfigWithLinks = [
desc: 'What are you using ?',
type: 'select',
options: [
{
key: 'temporal-cloud',
label: 'Cloud Metrics',
imgUrl: temporalUrl,
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
key: 'temporal-golang',
label: 'Go',

View File

@@ -22,7 +22,7 @@ import {
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -39,7 +39,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
signalSource,
setAttributeKeys,
}: AgregatorFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@@ -290,7 +289,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
return (
<AutoComplete
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
placeholder={getPlaceholder()}
style={selectStyle}
filterOption={false}

View File

@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { categoryToSupport } from './config';
import { selectStyles } from './styles';
@@ -13,7 +13,6 @@ function BuilderUnitsFilter({
onChange,
yAxisUnit,
}: IBuilderUnitsFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
const selectedValue = yAxisUnit || currentQuery?.unit;
@@ -37,7 +36,7 @@ function BuilderUnitsFilter({
Y-axis unit
</Typography.Text>
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
style={selectStyles}
onChange={onChangeHandler}
value={selectedValue}

View File

@@ -9,13 +9,12 @@ import {
} from 'lib/query/transformQueryBuilderData';
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../../utils';
import { HavingFilterProps, HavingTagRenderProps } from './types';
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = formula;
const [searchText, setSearchText] = useState<string>('');
const [localValues, setLocalValues] = useState<string[]>([]);
@@ -172,7 +171,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { OrderByProps } from './types';
@@ -13,7 +13,6 @@ function OrderByFilter({
onChange,
query,
}: OrderByProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
createOptions,
@@ -65,7 +64,7 @@ function OrderByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -33,7 +33,6 @@ export const GroupByFilter = memo(function GroupByFilter({
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
@@ -175,7 +174,7 @@ export const GroupByFilter = memo(function GroupByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -16,7 +16,7 @@ import {
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../utils';
// ** Types
@@ -27,7 +27,6 @@ export function HavingFilter({
query,
onChange,
}: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = query;
const [searchText, setSearchText] = useState<string>('');
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
@@ -232,7 +231,7 @@ export function HavingFilter({
return (
<>
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -14,7 +14,7 @@ import {
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -85,7 +85,6 @@ export const MetricNameSelector = memo(function MetricNameSelector({
signalSource,
'data-testid': dataTestId,
}: MetricNameSelectorProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
@@ -273,7 +272,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}

View File

@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
@@ -16,7 +16,6 @@ export function OrderByFilter({
entityVersion,
isNewQueryV2 = false,
}: OrderByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
selectedValue,
@@ -79,7 +78,7 @@ export function OrderByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -50,7 +50,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
@@ -95,7 +95,6 @@ function QueryBuilderSearch({
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
@@ -398,7 +397,7 @@ function QueryBuilderSearch({
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}

View File

@@ -50,7 +50,7 @@ import {
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from '../QueryBuilderSearch/config';
@@ -157,8 +157,6 @@ function QueryBuilderSearchV2(
selectProps,
} = props;
const getPopupContainer = useSelectPopupContainer();
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
@@ -991,7 +989,7 @@ function QueryBuilderSearchV2(
{...selectProps}
data-testid={'qb-search-select'}
ref={selectRef}
{...(hasPopupContainer ? { getPopupContainer } : {})}
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
{...(maxTagCount ? { maxTagCount } : {})}
key={queryTags.join('.')}
virtual={false}

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

@@ -126,15 +126,6 @@ export default function UPlotChart({
}
}, [isDataEmpty, destroyPlot]);
/**
* Destroy the plot on unmount. Without this, uPlot's window-level
* `dppxchange` listener keeps the instance (and its whole detached DOM
* subtree) alive after the component is gone.
*/
const destroyPlotRef = useRef(destroyPlot);
destroyPlotRef.current = destroyPlot;
useEffect(() => (): void => destroyPlotRef.current(), []);
/**
* Handle initialization and prop changes
*/

View File

@@ -327,32 +327,6 @@ describe('UPlotChart', () => {
expect(firstInstance.destroy).toHaveBeenCalled();
expect(instances).toHaveLength(2);
});
it('destroys the instance and notifies callbacks on unmount', () => {
const plotRef = jest.fn();
const onDestroy = jest.fn();
const { unmount } = render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={600}
height={400}
plotRef={plotRef}
onDestroy={onDestroy}
/>,
{ wrapper: Wrapper },
);
const firstInstance = instances[0];
plotRef.mockClear();
unmount();
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
expect(firstInstance.destroy).toHaveBeenCalledTimes(1);
expect(plotRef).toHaveBeenCalledWith(null);
});
});
describe('spanGaps data transformation', () => {

View File

@@ -16,13 +16,7 @@ import { sortBy } from 'lodash-es';
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
/** Telemetry signal — the generated enum (traces / logs / metrics). */
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
// also carries the empty "any" value used on field keys, which is not a valid
// query/variable signal, so exclude it here.
export type TelemetrySignal =
| TelemetrytypesSignalDTO.logs
| TelemetrytypesSignalDTO.traces
| TelemetrytypesSignalDTO.metrics;
export type TelemetrySignal = TelemetrytypesSignalDTO;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the

View File

@@ -17,9 +17,6 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
[TelemetrytypesSignalDTO.metrics]: 'metrics',
// The empty "any" signal only appears on field keys, never on a panel query;
// mapped for exhaustiveness.
[TelemetrytypesSignalDTO['']]: '',
};
/**

View File

@@ -1,6 +1,5 @@
import { ChevronDown } from '@signozhq/icons';
import { ColorPicker } from 'antd';
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import styles from './ThresholdsSection.module.scss';
@@ -12,11 +11,11 @@ interface ThresholdColorSelectProps {
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
// as quick swatches in the picker; the full picker below covers any custom color.
const PRESETS: { label: string; value: ThresholdColor }[] = [
{ label: 'Red', value: ThresholdColor.RED },
{ label: 'Orange', value: ThresholdColor.ORANGE },
{ label: 'Green', value: ThresholdColor.GREEN },
{ label: 'Blue', value: ThresholdColor.BLUE },
const PRESETS: { label: string; value: string }[] = [
{ label: 'Red', value: '#F1575F' },
{ label: 'Orange', value: '#F5B225' },
{ label: 'Green', value: '#2BB673' },
{ label: 'Blue', value: '#4E74F8' },
];
/**

View File

@@ -12,7 +12,6 @@ import {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import type { SectionEditorContext } from '../../sectionContext';
@@ -23,7 +22,7 @@ import TableThresholdRow from './rows/TableThresholdRow';
import styles from './ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = ThresholdColor.RED;
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
const ADD_TESTID: Record<ThresholdVariant, string> = {

View File

@@ -73,25 +73,6 @@ describe('usePanelEditorDraft', () => {
expect(result.current.isSpecDirty).toBe(false);
});
it('flags spec-dirty when the seed differs from the saved baseline (View handoff)', () => {
// The editor opens on a handed-off, already-edited spec (`seed`) but compares
// against the persisted panel (`saved`) — so it starts dirty, not clean.
const seed = panel('Memory', 'usage');
const saved = panel('CPU', 'usage');
const { result } = renderHook(() => usePanelEditorDraft(seed, saved));
expect(result.current.isSpecDirty).toBe(true);
});
it('is clean when the seed matches the saved baseline', () => {
const { result } = renderHook(() =>
usePanelEditorDraft(panel('CPU', 'usage'), panel('CPU', 'usage')),
);
expect(result.current.isSpecDirty).toBe(false);
});
it('reset restores the spec and clears dirty after an edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));

View File

@@ -1,201 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
// Exercises the REAL query builder provider (not mocks) so the dirty check is
// verified against the builder's actual re-serialization — the "always dirty"
// regression only reproduces with the real normalization in the loop.
const panelType = PANEL_TYPES.TIME_SERIES;
function makeSavedQueries(): DashboardtypesQueryDTO[] {
const base: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu',
},
],
},
};
return toPerses(base, panelType);
}
function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorQuerySync (real query builder)', () => {
it('an untouched existing panel is NOT query-dirty on mount', async () => {
const saved = makeSavedQueries();
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(saved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
// The builder force-resets to the saved query asynchronously; once settled the
// live query must serialize back to the saved queries → clean.
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
// And stays clean (no late re-stage flips it dirty).
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
// this as always-dirty; the round-tripped baseline normalizes both sides.
const minimalSaved: DashboardtypesQueryDTO[] = [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
aggregations: [
{ metricName: 'system_cpu_time', timeAggregation: 'avg' },
],
},
},
],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(minimalSaved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: minimalSaved,
}),
{ wrapper: AllTheProviders },
);
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 —
// not discard it — so the edit survives, and it must read dirty against saved.
const saved = makeSavedQueries();
const editedInUrl: Query = {
...initialQueriesMap[DataSource.METRICS],
id: 'edited-in-url',
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited-in-url',
},
],
},
};
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({
// The draft/preview open on the saved query…
draft: makePanel(saved),
panelType,
setSpec,
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper },
);
// The URL edit is retained → dirty, and it's synced into the draft so the
// preview follows (setSpec called with the edited query).
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
expect(setSpec).toHaveBeenCalled();
});
it('is query-dirty when the draft carries an edit the saved panel does not (View handoff)', async () => {
const saved = makeSavedQueries();
const editedBase: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited',
},
],
},
};
const editedQueries = toPerses(editedBase, panelType);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
// The builder seeds from the draft (the handed-off edit)…
draft: makePanel(editedQueries),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
// …but the baseline is the persisted panel.
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
});
});

View File

@@ -95,7 +95,6 @@ describe('usePanelEditorQuerySync', () => {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
savedQueries?: DashboardtypesPanelSpecDTO['queries'];
} = {},
): {
result: {
@@ -120,22 +119,20 @@ describe('usePanelEditorQuerySync', () => {
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
savedQueries: opts.savedQueries,
}),
);
return { result, setSpec, refetch, rerender };
}
it('seeds the builder from the draft queries on mount (URL query, when present, wins)', () => {
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
// No forceReset: useShareBuilderUrl resets to the seed only when the URL carries
// no query, so an in-editor edit in the URL survives a refresh.
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
forceReset: true,
});
});
@@ -345,127 +342,44 @@ describe('usePanelEditorQuerySync', () => {
});
});
describe('staged-query re-sync (browser back/forward)', () => {
it('commits the staged query into the draft when it re-stages', () => {
const state = builderState();
mockUseQueryBuilder.mockImplementation(() => state);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Browser Back re-stages a different query via initQueryBuilderData; the
// preview must follow it instead of keeping the last Run's result.
mockGetIsQueryModified.mockReturnValue(true);
state.stagedQuery = {
id: 'restaged',
queryType: 'builder',
} as unknown as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when only the live query changes (no re-stage)', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Live edit: currentQuery changes, staged query + structure unchanged.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('query dirty + save', () => {
// isQueryDirty compares the live query to the SAVED queries at the V5 envelope
// level (toQueryEnvelopes is mocked identity). Drive it via an input-sensitive
// toPerses so the envelope comparison — not getIsQueryModified — decides.
const SAVED_BASELINE = [{ id: 'saved-baseline' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const EDITED_ENVELOPES = [
{ id: 'edited-envelopes' },
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
beforeEach(() => {
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'edited' ? EDITED_ENVELOPES : SAVED_BASELINE,
// Baseline is the builder's own normalized staged query — immune to the
// raw-seed vs builder-normalized serialization drift.
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
expect.anything(),
STAGED_V1,
);
});
it('is query-dirty when the live query no longer serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('is not query-dirty when the live query still serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
it('is not query-dirty when the live query matches the baseline', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
it('buildSaveSpec bakes the live query in when dirty', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: EDITED_ENVELOPES,
queries: CONVERTED_QUERIES,
});
});
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toBe(spec);
});
it('anchors the baseline to savedQueries, not the draft the builder seeds from (View handoff / refresh)', () => {
// The draft carries the View-mode edit (the builder seeds from it), but the
// baseline is the persisted panel: a live query equal to the edited draft
// still reads dirty against the saved queries.
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const draft = makeDraft(EDITED_ENVELOPES);
const { result } = setup({ draft, savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('falls back to the seed query as the baseline when there are no saved queries (new panel)', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
});
});

View File

@@ -23,12 +23,6 @@ import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new
* panel or the drilldown modal, where the seed is the baseline.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
time?: PanelQueryTimeOverride;
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
@@ -73,15 +67,12 @@ export interface UsePanelEditSessionReturn {
export function usePanelEditSession({
panel,
panelId,
savedPanel,
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
const { draft, spec, setSpec, isSpecDirty, reset } =
usePanelEditorDraft(panel);
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
@@ -102,7 +93,6 @@ export function usePanelEditSession({
refetch: query.refetch,
alwaysSerializeQuery,
signal: seedQuerySignal ? defaultSignal : undefined,
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({

View File

@@ -13,14 +13,9 @@ import type { PanelEditorDraftApi } from '../types';
* preview renders it through the dashboard's renderer registry and the save hook
* patches it without conversion. Everything the config pane edits flows through the
* single `spec`/`setSpec` pair.
*
* `savedPanel` is the persisted panel the dirty check compares against — distinct from
* `initialPanel` (the seed), which may carry unsaved edits handed off from View mode.
* Defaults to the seed when there's no separate saved baseline (a new panel).
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
savedPanel: DashboardtypesPanelDTO = initialPanel,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
@@ -40,9 +35,9 @@ export function usePanelEditorDraft(
() =>
!isEqual(
{ ...draft, spec: { ...draft.spec, queries: null } },
{ ...savedPanel, spec: { ...savedPanel.spec, queries: null } },
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
),
[draft, savedPanel],
[draft, initialPanel],
);
return {

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -28,12 +27,6 @@ interface UsePanelEditorQuerySyncArgs {
alwaysSerializeQuery?: boolean;
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
signal?: TelemetrytypesSignalDTO;
/**
* The persisted panel's queries — the dirty baseline. Distinct from `draft.spec.queries`,
* which the builder seeds from and may carry unsaved edits handed off from View mode. Omit
* for a new panel, where the seed query is the baseline.
*/
savedQueries?: DashboardtypesQueryDTO[];
}
interface UsePanelEditorQuerySyncApi {
@@ -60,31 +53,43 @@ export function usePanelEditorQuerySync({
refetch,
alwaysSerializeQuery = false,
signal,
savedQueries,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
const draftQueries = draft.spec.queries;
// Saved queries, captured once: seed the builder and serve as the restore target.
const savedQueries = draft.spec.queries;
// A new panel has no saved query: seed from the kind's first supported signal rather
// than `fromPerses`'s metrics default (which List doesn't support).
// A new panel has no saved query: seed from the kind's first supported signal
// instead of letting `fromPerses` fall back to the metrics default (which List
// doesn't support).
const seedQuery = useMemo(
() =>
draftQueries.length === 0 && signal
savedQueries.length === 0 && signal
? initialQueriesMap[signal]
: fromPerses(draftQueries, panelType),
[draftQueries, panelType, signal],
: fromPerses(savedQueries, panelType),
[savedQueries, panelType, signal],
);
// No forceReset: seed the builder only when the URL carries no query, so an
// in-editor edit in the URL survives a refresh / browser Back-Forward.
useShareBuilderUrl({ defaultValue: seedQuery });
// Force-reset the builder to the SAVED panel on first render only, discarding a
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
// baseline is captured from the URL). After mount the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
forceReset: isInitialRenderRef.current,
});
useEffect(() => {
isInitialRenderRef.current = false;
}, []);
// Commit the live query into the draft (what the preview fetches).
// Commit the live query into the draft (what the preview fetches). The dirty
// check compares against the SAVED query (`seedQuery`), not the URL-synced
// staged query, which can carry stale state across a refresh and read a real
// switch as "unchanged". Returns whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
? toPerses(query, panelType)
: draftQueries;
: savedQueries;
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so a structural compare would falsely dirty the draft.
@@ -95,7 +100,7 @@ export function usePanelEditorQuerySync({
setSpec({ ...draft.spec, queries: next });
return true;
},
[seedQuery, panelType, draftQueries, draft.spec, setSpec],
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
);
// Latest query/commit, read by the structural-change effect without re-subscribing.
@@ -105,7 +110,7 @@ export function usePanelEditorQuerySync({
queryRef.current = currentQuery;
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the initial query is synced into the draft by the staged-query effect below.
// mount: the draft already holds the saved queries the builder is reset to.
const dataSources = useMemo(
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
[currentQuery.builder],
@@ -131,15 +136,6 @@ export function usePanelEditorQuerySync({
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);
// Follow the staged (executed) query into the draft on a URL re-stage (mount
// hydration, browser Back/Forward) so the preview matches. Live edits touch only
// currentQuery, so they still wait for Run; commitQuery no-ops when unchanged.
useEffect(() => {
if (stagedQuery) {
commitRef.current(stagedQuery);
}
}, [stagedQuery]);
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
@@ -148,29 +144,20 @@ export function usePanelEditorQuerySync({
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty = the live query no longer serializes to the SAVED panel's query, compared at
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
const baselineEnvelopes = useMemo(
() =>
toQueryEnvelopes(
toPerses(
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
panelType,
),
),
[savedQueries, seedQuery, panelType],
);
const isQueryDirty = useMemo(
() =>
!isEqual(
toQueryEnvelopes(toPerses(currentQuery, panelType)),
baselineEnvelopes,
),
[currentQuery, panelType, baselineEnvelopes],
);
// Dirty baseline: the builder's OWN normalized saved query (first non-null
// `stagedQuery` after the mount reset) — comparing builder-normalized to
// builder-normalized avoids serialization drift reading an untouched query as
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
// once and never moved by Stage & Run, so it stays anchored to saved.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
setQueryBaseline(stagedQuery);
}
}, [queryBaseline, stagedQuery]);
const isQueryDirty =
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>

View File

@@ -6,7 +6,6 @@ import {
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
@@ -42,22 +41,10 @@ import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
@@ -81,7 +68,6 @@ function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
@@ -105,7 +91,6 @@ function PanelEditorContainer({
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
@@ -303,24 +288,22 @@ function PanelEditorContainer({
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>

View File

@@ -22,22 +22,6 @@ export interface ComparisonThresholdShape {
format?: DashboardtypesThresholdFormatDTO;
}
/** SigNoz threshold palette; single source of truth for the hex values. */
export enum ThresholdColor {
RED = '#F1575F',
ORANGE = '#F5B225',
GREEN = '#2BB673',
BLUE = '#4E74F8',
}
/** Palette ordered most-dangerous first (preset order + alert-severity ranking). */
export const THRESHOLD_COLOR_DANGER_ORDER: ThresholdColor[] = [
ThresholdColor.RED,
ThresholdColor.ORANGE,
ThresholdColor.GREEN,
ThresholdColor.BLUE,
];
/** Comparison operators a threshold can use, as evaluable symbols. */
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';

View File

@@ -104,9 +104,7 @@ function ViewPanelModalContent({
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
};
return (

View File

@@ -105,8 +105,7 @@ export function useDrilldownDashboardVariables({
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(

View File

@@ -5,13 +5,11 @@ import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { normalizeOperator } from 'container/CreateAlertV2/context/conditionNormalizers';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
Threshold,
} from 'container/CreateAlertV2/context/types';
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import type { MetricAggregation } from 'types/api/v5/queryRange';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
@@ -23,6 +21,14 @@ export interface PanelAlertPrefill {
threshold?: Threshold;
}
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
const THRESHOLD_COLOR_DANGER_ORDER = [
'#f1575f',
'#f5b225',
'#2bb673',
'#4e74f8',
];
interface NormalizedPanelThreshold {
color: string;
value: number;
@@ -87,12 +93,8 @@ function readPanelThresholds(
}
}
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.
function colorRank(color: string): number {
const target = color.toLowerCase();
const index = THRESHOLD_COLOR_DANGER_ORDER.findIndex(
(paletteColor) => paletteColor.toLowerCase() === target,
);
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
}
@@ -104,17 +106,22 @@ function pickHighestDanger(
)[0];
}
// The alert UI has no inclusive operator; collapse "or equal" onto its strict variant.
function panelOperatorToAlertOperator(
operator: DashboardtypesComparisonOperatorDTO | undefined,
): AlertThresholdOperator | undefined {
switch (operator) {
case 'above':
case 'above_or_equal':
return normalizeOperator('above');
return AlertThresholdOperator.IS_ABOVE;
case 'below':
case 'below_or_equal':
return normalizeOperator('below');
return AlertThresholdOperator.IS_BELOW;
case 'equal':
return AlertThresholdOperator.IS_EQUAL_TO;
case 'not_equal':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
default:
return normalizeOperator(operator);
return undefined;
}
}

View File

@@ -2,7 +2,6 @@ import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
import { useShareVariablesOption } from './useShareVariablesOption';
import styles from './DashboardPageHeader.module.scss';
@@ -15,16 +14,10 @@ function DashboardPageHeader({
title,
image,
}: DashboardPageHeaderProps): JSX.Element {
const shareVariablesOption = useShareVariablesOption();
return (
<div className={styles.dashboardPageHeader}>
<DashboardPageBreadcrumbs title={title} image={image} />
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
shareModalExtraOption={shareVariablesOption}
/>
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { useMemo } from 'react';
import type { ShareURLExtraOption } from 'components/HeaderRightSection/ShareURLModal';
import type { SelectedVariableValue } from '../../VariablesBar/selectionTypes';
import {
ALL_SELECTED,
variablesUrlParser,
} from '../../VariablesBar/utils/variablesUrlState';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
/**
* The share-dialog "Include variables" option: serializes the current variable
* selection into the `?variables=` param (ALL encoded as the sentinel) so a shared
* link reproduces it for the recipient — who hydrates it into local storage on load,
* after which the param is cleared (see useSeedVariableSelection). Returns undefined
* when there is nothing selected to share.
*/
export function useShareVariablesOption(): ShareURLExtraOption | undefined {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const selections = useDashboardStore(selectVariableValues(dashboardId ?? ''));
return useMemo(() => {
const names = Object.keys(selections);
if (names.length === 0) {
return undefined;
}
const urlShape: Record<string, SelectedVariableValue> = {};
names.forEach((name) => {
const selection = selections[name];
urlShape[name] = selection.allSelected ? ALL_SELECTED : selection.value;
});
const serialized = variablesUrlParser.serialize(urlShape);
return {
label: 'Include variables',
apply: (params): void => {
params.set('variables', serialized);
},
};
}, [selections]);
}

View File

@@ -1,65 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useCreatePanel } from '../useCreatePanel';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useCreatePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window onto the new-panel route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
it('carries a custom absolute window and never a stray relativeTime', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
expect(url).not.toContain('relativeTime');
expect(url).not.toContain('&&');
});
});

View File

@@ -1,95 +0,0 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useOpenPanelEditor } from '../useOpenPanelEditor';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useOpenPanelEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window into the editor route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=6h',
undefined,
);
});
it('carries a custom absolute window as a start/end ms pair', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
// A custom range must not also carry relativeTime (it would win on the editor).
expect(url).not.toContain('relativeTime');
});
it('omits the query string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9',
undefined,
);
});
it('forwards handoff state as router location state', () => {
mockGlobalTime = { selectedTime: '1h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
const handoffState = { editSpec: { title: 'x' } } as never;
result.current('panel-9', { handoffState });
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=1h',
{ state: handoffState },
);
});
it('merges search with the time window (leading ? tolerated)', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('new', { search: '?panelKind=timeSeries&layoutIndex=2' });
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new?');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
});

View File

@@ -1,43 +0,0 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useTimeSearchParams } from '../useTimeSearchParams';
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
describe('useTimeSearchParams', () => {
it('returns a relativeTime query string for a relative selection', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('relativeTime=6h');
});
it('returns an absolute ms pair for a custom selection', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toContain('startTime=1000');
expect(result.current).toContain('endTime=2000');
expect(result.current).not.toContain('relativeTime');
});
it('returns an empty string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('');
});
});

View File

@@ -1,8 +1,11 @@
import { useCallback, useState } from 'react';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useOpenPanelEditor } from './useOpenPanelEditor';
import { useDashboardStore } from '../store/useDashboardStore';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -21,7 +24,8 @@ interface UseCreatePanelResult {
* until save.
*/
export function useCreatePanel(): UseCreatePanelResult {
const openPanelEditor = useOpenPanelEditor();
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
// Captured on open, consumed on select.
@@ -39,12 +43,15 @@ export function useCreatePanel(): UseCreatePanelResult {
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
setIsPickerOpen(false);
const target = targetIndex ?? layoutIndex;
openPanelEditor(NEW_PANEL_ID, {
search: newPanelSearch(panelKind, target),
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[openPanelEditor, layoutIndex],
[safeNavigate, dashboardId, layoutIndex],
);
return {

View File

@@ -5,39 +5,30 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from './useTimeSearchParams';
interface OpenPanelEditorOptions {
handoffState?: PanelEditorHandoffState;
/** Extra query merged into the editor URL (leading `?` optional). */
search?: string;
}
/** Opens the V2 panel editor, carrying the active time window in the URL. */
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. Variable selection is read from the
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
*/
export function useOpenPanelEditor(): (
panelId: string,
options?: OpenPanelEditorOptions,
handoffState?: PanelEditorHandoffState,
) => void {
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, options?: OpenPanelEditorOptions): void => {
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
});
const params = new URLSearchParams(options?.search);
new URLSearchParams(timeSearch).forEach((value, key) => {
params.set(key, value);
});
const search = params.toString();
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
safeNavigate(
search ? `${path}?${search}` : path,
options?.handoffState ? { state: options.handoffState } : undefined,
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId, timeSearch],
[safeNavigate, dashboardId],
);
}

View File

@@ -1,20 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
/** Active time window as a query string (no leading `?`), or `''` when unset. */
export function useTimeSearchParams(): string {
const { selectedTime, minTime, maxTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
return useMemo(
() => timeParamsFromGlobalTime({ selectedTime, minTime, maxTime }).toString(),
[selectedTime, minTime, maxTime],
);
}

View File

@@ -1,51 +0,0 @@
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { timeParamsFromGlobalTime } from '../timeUrlParams';
describe('timeParamsFromGlobalTime', () => {
it('emits relativeTime for a relative selection', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '6h',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('6h');
// Mutually exclusive: no absolute pair alongside a relative range.
expect(params.has('startTime')).toBe(false);
expect(params.has('endTime')).toBe(false);
});
it('emits an absolute ms pair for a custom selection (converting from ns)', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
});
expect(params.get('startTime')).toBe('1000');
expect(params.get('endTime')).toBe('2000');
// A custom range must not carry a relativeTime that would win on the editor.
expect(params.has('relativeTime')).toBe(false);
});
it('carries a custom shorthand relative selection verbatim', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '13m',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('13m');
});
it('emits nothing for an uninitialized custom window', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 0,
maxTime: 0,
});
expect(params.toString()).toBe('');
});
});

View File

@@ -1,39 +0,0 @@
import { QueryParams } from 'constants/query';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import type { GlobalReducer } from 'types/reducer/globalTime';
type GlobalTimeSelection = Pick<
GlobalReducer,
'selectedTime' | 'minTime' | 'maxTime'
>;
/**
* Time-window URL params for the active selection. Derived from Redux (what the picker and
* panel queries read), not the URL: the legacy react-router and newer nuqs time writers fall
* out of sync, leaving a stale `relativeTime` that `DateTimeSelectionV2` prefers over an
* absolute range. Redux keeps them mutually exclusive (custom → start/end ms; else relativeTime).
*/
export function timeParamsFromGlobalTime({
selectedTime,
minTime,
maxTime,
}: GlobalTimeSelection): URLSearchParams {
const params = new URLSearchParams();
if (selectedTime === 'custom') {
if (minTime > 0 && maxTime > 0) {
params.set(
QueryParams.startTime,
String(Math.floor(minTime / NANO_SECOND_MULTIPLIER)),
);
params.set(
QueryParams.endTime,
String(Math.floor(maxTime / NANO_SECOND_MULTIPLIER)),
);
}
} else {
params.set(QueryParams.relativeTime, selectedTime);
}
return params;
}

View File

@@ -21,7 +21,6 @@ import {
parseNewPanelLayoutIndex,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
@@ -39,7 +38,6 @@ function PanelEditorPage(): JSX.Element {
}>();
const { search, state } = useLocation();
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
// Edits handed off from the View modal's drilldown — open the editor on these
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
@@ -107,11 +105,10 @@ function PanelEditorPage(): JSX.Element {
const layoutIndex = parseNewPanelLayoutIndex(search);
const backToDashboard = useCallback((): void => {
// Drop editor-only URL state (variables come from the persisted store), but carry
// time so a custom range picked in the editor isn't reset to the dashboard default.
const path = generatePath(ROUTES.DASHBOARD, { dashboardId });
safeNavigate(timeSearch ? `${path}?${timeSearch}` : path);
}, [safeNavigate, dashboardId, timeSearch]);
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
// variable selection from the persisted store, and time lives in Redux.
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [safeNavigate, dashboardId]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
@@ -140,7 +137,6 @@ function PanelEditorPage(): JSX.Element {
dashboardId={dashboardId}
panelId={panelId}
panel={panel}
savedPanel={existingPanel}
isNew={!!newKind}
layoutIndex={layoutIndex}
isEditable={isEditable}

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;

View File

@@ -1,20 +1,5 @@
import { ConfigProvider, SelectProps } from 'antd';
// eslint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { SelectProps } from 'antd';
export const popupContainer: SelectProps['getPopupContainer'] = (
trigger,
): HTMLElement => trigger.parentNode;
/**
* Popup container for query-builder Selects. Prefers a container supplied by an
* ancestor antd `ConfigProvider` (set by hosts that render the builder inside a
* clipped/portaled surface — e.g. the panel editor's `overflow:hidden` resizable
* pane, or the View modal's focus-trapped dialog) and otherwise falls back to
* `trigger.parentNode`, the app-wide default. No `ConfigProvider` container is set
* app-wide, so surfaces that don't opt in keep the legacy behavior unchanged.
*/
export function useSelectPopupContainer(): SelectProps['getPopupContainer'] {
const { getPopupContainer } = useContext(ConfigProvider.ConfigContext);
return getPopupContainer ?? popupContainer;
}

View File

@@ -56,21 +56,19 @@ func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (
}
}
// Without executing the series lookup, only an exact-name selector's
// metric name is known.
var metricNames []string
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" && matcher.Type == prompb.LabelMatcher_EQ {
metricNames = []string{matcher.Value}
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
// Build the executing path's queries, but only record them.
sub, err := seriesLookupQuery(query, true)
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil

View File

@@ -4,7 +4,8 @@ import (
"context"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
@@ -14,7 +15,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/cespare/xxhash/v2"
"github.com/huandu/go-sqlbuilder"
promValue "github.com/prometheus/prometheus/model/value"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
@@ -56,13 +56,19 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
}
}
lookup, err := seriesLookupQuery(query, false)
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
clickhouseQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, false)
if err != nil {
return nil, err
}
lookupSQL, lookupArgs := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
fingerprints, metricNames, err := client.getFingerprintsFromClickhouseQuery(ctx, lookupSQL, lookupArgs)
fingerprints, err := client.getFingerprintsFromClickhouseQuery(ctx, clickhouseQuery, args)
if err != nil {
return nil, err
}
@@ -70,14 +76,13 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
return remote.FromQueryResult(sortSeries, new(prompb.QueryResult)), nil
}
sub, err := seriesLookupQuery(query, true)
clickhouseSubQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesSQL, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
res := new(prompb.QueryResult)
timeseries, err := client.querySamples(ctx, samplesSQL, samplesArgs, fingerprints)
timeseries, err := client.querySamples(ctx, int64(query.StartTimestampMs), int64(query.EndTimestampMs), fingerprints, metricName, clickhouseSubQuery, args)
if err != nil {
return nil, err
}
@@ -121,115 +126,86 @@ func (c *client) ReadMultiple(ctx context.Context, queries []*prompb.Query, sort
return storage.NewMergeSeriesSet(sets, 0, storage.ChainedSeriesMerge), nil
}
// anchorRegex makes a pattern fully anchored, the way Prometheus compiles
// matcher regexes; ClickHouse's match() would otherwise substring-match.
func anchorRegex(pattern string) string {
return "^(?:" + pattern + ")$"
}
// seriesLookupQuery builds the time-series lookup. It returns a builder so
// the samples query can embed it as a subquery with the args merged in
// render order by the builder instead of hand-numbered placeholders.
func seriesLookupQuery(query *prompb.Query, subQuery bool) (*sqlbuilder.SelectBuilder, error) {
sb := sqlbuilder.NewSelectBuilder()
func (client *client) queryToClickhouseQuery(_ context.Context, query *prompb.Query, metricName string, subQuery bool) (string, []any, error) {
var clickHouseQuery string
var conditions []string
var argCount = 0
var selectString = "fingerprint, any(labels)"
if subQuery {
sb.Select("fingerprint")
} else {
sb.Select("fingerprint", "any(labels)")
argCount = 1
selectString = "fingerprint"
}
start, end, tableName := getStartAndEndAndTableName(query.StartTimestampMs, query.EndTimestampMs)
sb.From(databaseName + "." + tableName)
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
var args []any
conditions = append(conditions, fmt.Sprintf("metric_name = $%d", argCount+1))
conditions = append(conditions, "temporality IN ['Cumulative', 'Unspecified']")
// Inclusive upper bound: registration rows are hour-floored by the
// exporter, so a series first registered in the hour starting exactly at
// `end` would otherwise be invisible while its samples (<= end) are in
// range.
sb.Where(fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
args = append(args, metricName)
for _, m := range query.Matchers {
if m.Name == "__name__" {
// __name__ maps onto the metric_name column per matcher type;
// reducing regex/negated/absent name matchers to one equality
// made such selectors silently return empty.
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(sb.E("metric_name", m.Value))
case prompb.LabelMatcher_NEQ:
sb.Where(sb.NE("metric_name", m.Value))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
continue
}
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) = $%d", argCount+2, argCount+3))
case prompb.LabelMatcher_NEQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) != $%d", argCount+2, argCount+3))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
args = append(args, m.Name, m.Value)
argCount += 2
}
sb.GroupBy("fingerprint")
return sb, nil
whereClause := strings.Join(conditions, " AND ")
clickHouseQuery = fmt.Sprintf(`SELECT %s FROM %s.%s WHERE %s GROUP BY fingerprint`, selectString, databaseName, tableName, whereClause)
return clickHouseQuery, args, nil
}
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, []string, error) {
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, error) {
ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, nil, err
return nil, err
}
defer rows.Close()
fingerprints := make(map[uint64][]prompb.Label)
nameSet := make(map[string]struct{})
var fingerprint uint64
var labelString string
for rows.Next() {
if err = rows.Scan(&fingerprint, &labelString); err != nil {
return nil, nil, err
return nil, err
}
labels, metricName, err := unmarshalLabels(labelString)
labels, _, err := unmarshalLabels(labelString)
if err != nil {
return nil, nil, err
return nil, err
}
fingerprints[fingerprint] = labels
if metricName != "" {
nameSet[metricName] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return nil, nil, err
return nil, err
}
metricNames := make([]string, 0, len(nameSet))
for name := range nameSet {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
return fingerprints, metricNames, nil
return fingerprints, nil
}
// buildSamplesQuery renders the samples SQL for the series selected by
// subQuery. The metric_name condition exists only for primary-key pruning;
// the fingerprint filter already selects the right rows.
// buildSamplesQuery renders the samples SQL (and args) that fetches data
// points for the series selected by subQuery.
//
// Time bounds are inclusive on both ends because that is Prometheus's
// storage contract: Select(mint, maxt) returns [start, end] and the engine
@@ -239,29 +215,27 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
// its own model — toStartOfInterval buckets covering [t, t+step), where a
// sample at `end` falls in an unrendered bucket and end-exclusive ranges
// tile exactly across cached time slices.
func buildSamplesQuery(start int64, end int64, metricNames []string, sub *sqlbuilder.SelectBuilder) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "fingerprint", "unix_milli", "value", "flags")
sb.From(databaseName + "." + distributedSamplesV4)
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
argCount := len(args)
if len(metricNames) > 0 {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb.Where(sb.In("metric_name", names...))
}
sb.Where(fmt.Sprintf("fingerprint GLOBAL IN (%s)", sb.Var(sub)))
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
sb.OrderBy("fingerprint", "unix_milli")
query := fmt.Sprintf(`
SELECT metric_name, fingerprint, unix_milli, value, flags
FROM %s.%s
WHERE metric_name = $1 AND fingerprint GLOBAL IN (%s) AND unix_milli >= $%s AND unix_milli <= $%s ORDER BY fingerprint, unix_milli;`,
databaseName, distributedSamplesV4, subQuery, strconv.Itoa(argCount+2), strconv.Itoa(argCount+3))
query = strings.TrimSpace(query)
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
allArgs := append([]any{metricName}, args...)
allArgs = append(allArgs, start, end)
return query, allArgs
}
func (client *client) querySamples(ctx context.Context, query string, args []any, fingerprints map[uint64][]prompb.Label) ([]*prompb.TimeSeries, error) {
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
if err != nil {
return nil, err
}
@@ -269,7 +243,6 @@ func (client *client) querySamples(ctx context.Context, query string, args []any
var res []*prompb.TimeSeries
var ts *prompb.TimeSeries
var metricName string
var fingerprint, prevFingerprint uint64
var timestampMs, prevTimestamp int64
var value float64

View File

@@ -11,7 +11,6 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/huandu/go-sqlbuilder"
"github.com/prometheus/prometheus/prompb"
"github.com/stretchr/testify/assert"
)
@@ -30,7 +29,7 @@ func TestClient_QuerySamples(t *testing.T) {
start int64
end int64
fingerprints map[uint64][]prompb.Label
metricNames []string
metricName string
subQuery string
args []any
setupMock func(mock cmock.ClickConnMockCommon, args ...any)
@@ -53,7 +52,7 @@ func TestClient_QuerySamples(t *testing.T) {
{Name: "instance", Value: "localhost:9091"},
},
},
metricNames: []string{"cpu_usage"},
metricName: "cpu_usage",
subQuery: "SELECT metric_name, fingerprint, unix_milli, value, flags",
expectedTimeSeries: 2,
expectError: false,
@@ -98,10 +97,10 @@ func TestClient_QuerySamples(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
readClient := client{telemetryStore: telemetryStore}
if tt.setupMock != nil {
tt.setupMock(telemetryStore.Mock(), "cpu_usage", tt.start, tt.end)
tt.setupMock(telemetryStore.Mock(), tt.metricName, tt.start, tt.end)
}
result, err := readClient.querySamples(ctx, tt.subQuery, []any{"cpu_usage", tt.start, tt.end}, tt.fingerprints)
result, err := readClient.querySamples(ctx, tt.start, tt.end, tt.fingerprints, tt.metricName, tt.subQuery, tt.args)
if tt.expectError {
assert.Error(t, err)
@@ -116,6 +115,101 @@ func TestClient_QuerySamples(t *testing.T) {
}
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"t1":"s1","t2":"s2"}`},
{uint64(234), `{"t1":"s1","t2":"s2","empty":""}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
// No synthetic fingerprint label (#8563), empty-valued labels
// dropped: both fingerprints present one labelset for
// querySamples to merge.
want: map[uint64][]prompb.Label{
123: {
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}
// Regression for the duplicate-series class behind #8563: fingerprints
// sharing one labelset must come back as one merged series, the higher
// fingerprint winning equal timestamps.
@@ -157,7 +251,7 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
WillReturnRows(cmock.NewRows(cols, values))
readClient := client{telemetryStore: telemetryStore}
result, err := readClient.querySamples(ctx, "SELECT metric_name, fingerprint, unix_milli, value, flags", []any{"requests", int64(1000), int64(3000)}, fingerprints)
result, err := readClient.querySamples(ctx, 1000, 3000, fingerprints, "requests", "SELECT metric_name, fingerprint, unix_milli, value, flags", nil)
require.NoError(t, err)
assert.Equal(t, []*prompb.TimeSeries{
@@ -178,188 +272,6 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
}, result)
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantNames []string
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"__name__":"cpu_usage","t1":"s1","t2":"s2"}`},
{uint64(234), `{"__name__":"cpu_usage","t1":"s1","t2":"s2","empty":""}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
// No synthetic fingerprint label (#8563), empty-valued labels
// dropped: both fingerprints present one labelset for
// querySamples to merge.
want: map[uint64][]prompb.Label{
123: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
wantNames: []string{"cpu_usage"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, gotNames, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantNames, gotNames, "discovered metric names mismatch")
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}
// Regression for nameless/regex-name selectors silently returning empty:
// the old code reduced every __name__ matcher to `metric_name = <value>`
// (empty string when absent). Regexes must come out anchored — Prometheus
// matcher semantics, while ClickHouse match() substring-matches.
func TestQueryToClickhouseQueryNameMatchers(t *testing.T) {
query := func(matchers ...*prompb.LabelMatcher) *prompb.Query {
return &prompb.Query{StartTimestampMs: 0, EndTimestampMs: 1000, Matchers: matchers}
}
tests := []struct {
name string
query *prompb.Query
contains []string
absent []string
args []any
}{
{
name: "exact name",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "__name__", Value: "cpu_usage"}),
contains: []string{"metric_name = ?"},
args: []any{"cpu_usage"},
},
{
name: "regex name is anchored",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_RE, Name: "__name__", Value: ".+"}),
contains: []string{"match(metric_name, ?)"},
args: []any{"^(?:.+)$"},
},
{
name: "nameless selector has no metric_name condition",
query: query(
&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "job", Value: "api"},
&prompb.LabelMatcher{Type: prompb.LabelMatcher_NRE, Name: "group", Value: "can.*"},
),
contains: []string{
"JSONExtractString(labels, ?) = ?",
"not match(JSONExtractString(labels, ?), ?)",
},
absent: []string{"metric_name"},
args: []any{"job", "api", "group", "^(?:can.*)$"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lookup, err := seriesLookupQuery(tt.query, false)
require.NoError(t, err)
sql, args := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
for _, want := range tt.contains {
assert.Contains(t, sql, want)
}
for _, notWant := range tt.absent {
assert.NotContains(t, sql, notWant)
}
assert.Equal(t, tt.args, args)
})
}
}
// The samples query narrows by the metric names the lookup discovered and
// embeds the series lookup as a subquery, the builder merging its args in
// render order.
func TestBuildSamplesQueryMetricNames(t *testing.T) {
sub := sqlbuilder.NewSelectBuilder()
sub.Select("fingerprint")
sub.From("t")
sub.Where(sub.E("k", "v"))
sql, args := buildSamplesQuery(5, 9, []string{"a_total", "b_total"}, sub)
assert.Contains(t, sql, "metric_name IN (?, ?)")
assert.Contains(t, sql, "fingerprint GLOBAL IN (SELECT fingerprint FROM t WHERE k = ?)")
assert.Contains(t, sql, "unix_milli >= ? AND unix_milli <= ?")
assert.Equal(t, []any{"a_total", "b_total", "v", int64(5), int64(9)}, args)
sub2 := sqlbuilder.NewSelectBuilder()
sub2.Select("fingerprint")
sub2.From("t")
sql, args = buildSamplesQuery(5, 9, nil, sub2)
assert.NotContains(t, sql, "metric_name IN")
assert.Equal(t, []any{int64(5), int64(9)}, args)
}
// Hash grouping must stay order-insensitive (stored JSON key order is not
// canonical across fingerprints), and a 64-bit hash collision between
// distinct labelsets must not merge them — splitByLabelSet is that guard.

View File

@@ -237,7 +237,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
// Mock the fingerprint query (for Prometheus label matching)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
@@ -245,6 +245,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).

View File

@@ -925,18 +925,20 @@ func TestPromRuleUnitCombinations(t *testing.T) {
}
samplesRows := cmock.NewRows(samplesCols, samplesData)
// args: $1=metric_name (the __name__ matcher maps onto the column)
// args: $1=metric_name, $2=label_name, $3=label_value
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// args: $1=metric_name IN (discovered names), $2=metric_name (subquery), $3=start, $4=end
// args: $1=metric_name (outer), $2=metric_name (subquery), $3=label_name, $4=label_value, $5=start, $6=end
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
@@ -1061,7 +1063,7 @@ func TestPromRuleNoData(t *testing.T) {
// no rows == no data
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
@@ -1271,7 +1273,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
telemetryStore.Mock().
@@ -1279,6 +1281,8 @@ func TestMultipleThresholdPromRule(t *testing.T) {
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
@@ -1435,12 +1439,12 @@ func TestPromRule_NoData(t *testing.T) {
labelsJSON := `{"__name__":"test_metric"}`
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
promProvider := prometheustest.New(
@@ -1571,11 +1575,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
queryStart1, queryEnd1 := calcQueryRange(t1)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart1, queryEnd1).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
// Data points in the past relative to t1
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
@@ -1587,11 +1591,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
queryStart2, queryEnd2 := calcQueryRange(t2)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart2, queryEnd2).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data
promProvider := prometheustest.New(
@@ -1748,11 +1752,11 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, samplesData))
promProvider := prometheustest.New(
context.Background(),

View File

@@ -181,15 +181,6 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
// dynamic array elements will be default casted to string
tblFieldName, value = castString(tblFieldName), toStrings(v)
}
case telemetrytypes.FieldDataTypeUnspecified:
if operator == qbtypes.FilterOperatorUnknown {
switch value.(type) {
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
tblFieldName = accurateCastFloat(tblFieldName)
case string:
tblFieldName = castString(tblFieldName)
}
}
}
return tblFieldName, value
}

View File

@@ -3,6 +3,7 @@ package querybuilder
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -13,8 +14,38 @@ import (
"github.com/tidwall/gjson"
)
var telemetryGrantKeys = map[string]struct{}{
"service.name": {},
}
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
func EscapeTelemetryValue(value string) string {
var escaped strings.Builder
for _, character := range []byte(value) {
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
escaped.WriteByte(character)
continue
}
escaped.WriteString(fmt.Sprintf("%%%02X", character))
}
return escaped.String()
}
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
values := telemetrytypes.NewTelemetryGrantSelectors(id)
values := []string{id}
segments := strings.Split(id, "/")
for level := len(segments) - 1; level >= 1; level-- {
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
if value == id {
continue
}
values = append(values, value)
}
if id != coretypes.WildCardSelectorString {
values = append(values, coretypes.WildCardSelectorString)
}
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
@@ -156,14 +187,14 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
continue
}
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
key, ok := canonicalTelemetryGrantKey(condition.Key)
if !ok {
continue
}
if condition.Operator == "=" || condition.Operator == "IN" {
for _, value := range condition.Values {
ids = append(ids, queryType+"/"+key+"/"+value)
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
}
}
}
@@ -174,3 +205,16 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
return ids, nil
}
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
return "", false
}
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
return "", false
}
return fieldKey.Name, true
}

View File

@@ -2,6 +2,7 @@ package querybuilder
import (
"context"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -21,33 +22,33 @@ func TestQueryRangeResources(t *testing.T) {
expected []coretypes.ResourceWithID
}{
{
name: "top level key equality",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
name: "top level service equality",
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "resource prefixed key",
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
name: "resource prefixed service key",
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "in atom requires every value",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
name: "multiple equality atoms each require a grant",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
@@ -58,38 +59,38 @@ func TestQueryRangeResources(t *testing.T) {
},
},
{
name: "key atom under or does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
name: "service atom under or does not qualify",
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "negated key atom does not qualify",
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
name: "negated service atom does not qualify",
body: builderQueryBody("logs", "NOT service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "key inequality does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
name: "service inequality does not qualify",
body: builderQueryBody("logs", "service.name != 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "value with spaces and slashes stays plaintext in the id",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
name: "unsafe value bytes are escaped",
body: builderQueryBody("logs", "service.name = 'check out/2'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
},
},
{
name: "audit source maps to audit logs resource",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/service.name/a"},
},
},
{
@@ -116,23 +117,23 @@ func TestQueryRangeResources(t *testing.T) {
},
{
name: "trace operator rides on its referenced queries",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "variable substitution qualifies",
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "duplicate queries dedupe",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
},
},
}
@@ -150,7 +151,7 @@ func TestQueryRangeResourcesErrors(t *testing.T) {
bodies := []string{
`{"compositeQuery":{"queries":[]}}`,
`{}`,
builderQueryBody("logs", "signoz.workspace.key.id = "),
builderQueryBody("logs", "service.name = "),
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
}
@@ -174,10 +175,10 @@ func TestTelemetrySelector(t *testing.T) {
return values
}
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
assert.Equal(t, []string{"*"}, selectorValues("*"))
// a value containing "/" stays one logical segment (SplitN 3).
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), orgID)
assert.Error(t, err)
}

View File

@@ -162,28 +162,18 @@ func (c *conditionBuilder) ConditionFor(
return nil, nil, err
}
keys := querybuilder.MatchingFieldKeys(key, fieldKeys)
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
keys = []*telemetrytypes.TelemetryFieldKey{key}
} else {
if len(fieldKeys[key.Name]) == 0 {
warnings = append(warnings, fmt.Sprintf("label `%s` not found in metadata; check the label name for typos", key.Name))
}
keys = []*telemetrytypes.TelemetryFieldKey{
telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType),
}
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(
key.FieldContext.StringValue()+"."+key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType))
}
}
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, k, operator, value, sb)
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
@@ -191,3 +181,21 @@ func (c *conditionBuilder) ConditionFor(
}
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, operator, value, sb)
if err != nil {
return "", err
}
return condition, nil
}

View File

@@ -307,86 +307,3 @@ func TestConditionForMultipleKeys(t *testing.T) {
})
}
}
func TestConditionForKeyNotInMetadata(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
expectedSQL []string
expectWarn bool
}{
{
name: "intrinsic metric_name full-text resolves without warning",
key: telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorRegexp,
value: "k8s",
expectedSQL: []string{"match(metric_name, ?)"},
expectWarn: false,
},
{
name: "unknown label resolves to labels extract with a typo warning",
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextUnspecified},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorEqual,
value: "bar",
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?"},
expectWarn: true,
},
{
name: "context prefix that may be part of the name tries both readings",
key: telemetrytypes.TelemetryFieldKey{Name: "a.b.c", FieldContext: telemetrytypes.FieldContextScope},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorEqual,
value: "x",
expectedSQL: []string{"JSONExtractString(labels, 'a.b.c') = ?", "JSONExtractString(labels, 'scope.a.b.c') = ?"},
expectWarn: true,
},
{
name: "unresolved metric-context name is treated as a label prefix",
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextMetric},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorEqual,
value: "bar",
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?", "JSONExtractString(labels, 'metric.foo') = ?"},
expectWarn: true,
},
{
name: "known label under a mismatched context collapses without warning",
key: telemetrytypes.TelemetryFieldKey{Name: "region", FieldContext: telemetrytypes.FieldContextResource},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
"region": {{Name: "region", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
},
operator: qbtypes.FilterOperatorEqual,
value: "us",
expectedSQL: []string{"JSONExtractString(labels, 'region') = ?"},
expectWarn: false,
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
cond, warnings, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, tc.fieldKeys, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
require.NoError(t, err)
sb.Where(cond...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
for _, want := range tc.expectedSQL {
assert.Contains(t, sql, want)
}
if tc.expectWarn {
assert.NotEmpty(t, warnings)
} else {
assert.Empty(t, warnings)
}
})
}
}

View File

@@ -49,24 +49,23 @@ func (c *conditionBuilder) conditionFor(
// TODO(srikanthccv): maybe extend this to every possible attribute
if key.Name == "duration_nano" || key.Name == "durationNano" { // QoL improvement
switch v := value.(type) {
case string:
if duration, err := time.ParseDuration(v); err == nil {
if strDuration, ok := value.(string); ok {
duration, err := time.ParseDuration(strDuration)
if err == nil {
value = duration.Nanoseconds()
} else if f, err := strconv.ParseFloat(v, 64); err == nil {
value = int64(f)
} else {
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", v)
duration, err := strconv.ParseFloat(strDuration, 64)
if err == nil {
value = duration
} else {
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", strDuration)
}
}
case float64:
value = int64(v)
case float32:
value = int64(v)
}
} else {
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
}
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
// regular operators
switch operator {
// regular operators

View File

@@ -367,7 +367,7 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,

View File

@@ -343,7 +343,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, duration_nano, mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400)},
},
expectedErr: nil,

View File

@@ -6,7 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -107,6 +106,10 @@ func NewGettableTransaction(results []*TransactionWithAuthorization) []*Gettable
return gettableTransactions
}
func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, deletions TransactionGroups) {
return desired.subtract(groups), groups.subtract(desired)
}
func (groups TransactionGroups) Value() (driver.Value, error) {
data, err := json.Marshal(groups)
if err != nil {
@@ -165,6 +168,51 @@ func (transaction *Transaction) TransactionKey() string {
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
}
func (groups TransactionGroups) subtract(other TransactionGroups) TransactionGroups {
otherSelectors := other.selectorSet()
order := make([]string, 0)
grouped := make(map[string]*TransactionGroup)
for _, group := range groups {
for _, selector := range group.ObjectGroup.Selectors {
if _, ok := otherSelectors[group.selectorKey(selector)]; ok {
continue
}
groupKey := group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String()
out, ok := grouped[groupKey]
if !ok {
out = &TransactionGroup{Relation: group.Relation, ObjectGroup: coretypes.ObjectGroup{Resource: group.ObjectGroup.Resource, Selectors: make([]coretypes.Selector, 0)}}
grouped[groupKey] = out
order = append(order, groupKey)
}
out.ObjectGroup.Selectors = append(out.ObjectGroup.Selectors, selector)
}
}
result := make(TransactionGroups, 0, len(order))
for _, key := range order {
result = append(result, grouped[key])
}
return result
}
func (groups TransactionGroups) selectorSet() map[string]struct{} {
set := make(map[string]struct{})
for _, group := range groups {
for _, selector := range group.ObjectGroup.Selectors {
set[group.selectorKey(selector)] = struct{}{}
}
}
return set
}
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
}
func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup, error) {
verb, err := coretypes.NewVerb(raw.Relation)
if err != nil {
@@ -188,13 +236,6 @@ func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup,
selectors := make([]coretypes.Selector, 0, len(raw.ObjectGroup.Selectors))
for selectorIndex, rawSelector := range raw.ObjectGroup.Selectors {
if resourceType.Equals(coretypes.TypeTelemetryResource) {
rawSelector, err = telemetrytypes.NewTelemetryGrantSelector(rawSelector)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
}
}
selector, err := resourceType.Selector(rawSelector)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())

View File

@@ -3,7 +3,6 @@ package authtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
@@ -69,38 +68,6 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
return tuples, nil
}
func DiffTuples(existing, desired []*openfgav1.TupleKey) (additions, deletions []*openfgav1.TupleKey) {
key := func(tuple *openfgav1.TupleKey) string {
return tuple.GetUser() + "|" + tuple.GetRelation() + "|" + tuple.GetObject()
}
existingSet := make(map[string]struct{}, len(existing))
for _, tuple := range existing {
existingSet[key(tuple)] = struct{}{}
}
desiredSet := make(map[string]struct{}, len(desired))
for _, tuple := range desired {
desiredSet[key(tuple)] = struct{}{}
}
additions = make([]*openfgav1.TupleKey, 0)
for _, tuple := range desired {
if _, ok := existingSet[key(tuple)]; !ok {
additions = append(additions, tuple)
}
}
deletions = make([]*openfgav1.TupleKey, 0)
for _, tuple := range existing {
if _, ok := desiredSet[key(tuple)]; !ok {
deletions = append(deletions, tuple)
}
}
return additions, deletions
}
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
objectsByRelation := make(map[string][]*coretypes.Object)
@@ -142,29 +109,17 @@ func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subj
return nil, nil, err
}
selectorStrings, err := newCheckSelectors(txn.Object.Resource.Type, txn.Object.Selector)
if err != nil {
return nil, nil, err
}
selectors := make([]coretypes.Selector, 0, len(selectorStrings))
for _, selectorString := range selectorStrings {
selector, err := txn.Object.Resource.Type.Selector(selectorString)
if err != nil {
return nil, nil, err
}
selectors = append(selectors, selector)
}
txnID := txn.ID.StringValue()
for index, tuple := range NewTuples(resource, subject, txn.Relation, selectors, orgID) {
if index == 0 {
tuples[txnID] = tuple
continue
}
txnTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{txn.Object.Selector}, orgID)
tuples[txnID] = txnTuples[0]
if txn.Object.Selector.String() != coretypes.WildCardSelectorString {
wildcardSelector := txn.Object.Resource.Type.MustSelector(coretypes.WildCardSelectorString)
wildcardTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{wildcardSelector}, orgID)
correlationID := valuer.GenerateUUID().StringValue()
tuples[correlationID] = tuple
tuples[correlationID] = wildcardTuples[0]
correlations[txnID] = append(correlations[txnID], correlationID)
}
}
@@ -259,21 +214,3 @@ func NewTransactionWithAuthorizationFromBatchResults(
return output
}
func newCheckSelectors(resourceType coretypes.Type, selector coretypes.Selector) ([]string, error) {
if resourceType.Equals(coretypes.TypeTelemetryResource) {
canonical, err := telemetrytypes.NewTelemetryGrantSelector(selector.String())
if err != nil {
return nil, err
}
return telemetrytypes.NewTelemetryGrantSelectors(canonical), nil
}
selectorStrings := []string{selector.String()}
if selector.String() != coretypes.WildCardSelectorString {
selectorStrings = append(selectorStrings, coretypes.WildCardSelectorString)
}
return selectorStrings, nil
}

View File

@@ -137,6 +137,13 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
return nil, err
}
account.Config.Azure = azureConfig
case CloudProviderTypeGCP:
gcpConfig := new(GCPAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
if err != nil {
return nil, err
}
account.Config.GCP = gcpConfig
}
if storableAccount.LastAgentReport != nil {

View File

@@ -23,5 +23,5 @@ var (
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^.{1,512}$`), []Verb{VerbRead}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|[a-z_]{1,32}(/(\*|[A-Za-z0-9._%-]{1,128})){0,2})$`), []Verb{VerbRead}}
)

View File

@@ -1,9 +1,6 @@
package coretypes
import (
"crypto/sha256"
"encoding/hex"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -29,18 +26,7 @@ func (resourceTelemetryResource *resourceTelemetryResource) Prefix(orgID valuer.
}
func (resourceTelemetryResource *resourceTelemetryResource) Object(orgID valuer.UUID, selector string) string {
if selector == WildCardSelectorString {
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
}
return resourceTelemetryResource.Prefix(orgID) + "/" + telemetrySelectorHash(selector)
}
// Must stay stable: grant-time and check-time tuple objects both hash the selector
// here, so changing this invalidates every stored telemetry grant tuple.
func telemetrySelectorHash(selector string) string {
sum := sha256.Sum256([]byte(selector))
return hex.EncodeToString(sum[:16])
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
}
func (resourceTelemetryResource *resourceTelemetryResource) Scope(verb Verb) string {

View File

@@ -613,7 +613,7 @@ func (o OrderBy) Copy() OrderBy {
type SecondaryAggregation struct {
// stepInterval of the query
// if not set, it will use the step interval of the primary aggregation
StepInterval Step `json:"stepInterval,omitzero"`
StepInterval Step `json:"stepInterval,omitempty"`
// expression to aggregate. example: count(), sum(item_price), countIf(day > 10)
Expression string `json:"expression"`
// if any, it will be used as the alias of the aggregation in the result

View File

@@ -16,7 +16,7 @@ type QueryBuilderQuery[T any] struct {
Name string `json:"name"`
// stepInterval of the query
StepInterval Step `json:"stepInterval,omitzero"`
StepInterval Step `json:"stepInterval,omitempty"`
// signal to query
Signal telemetrytypes.Signal `json:"signal,omitempty"`

View File

@@ -709,40 +709,3 @@ func TestQueryBuilderQuery_MetricAggregation_MarshalJSONEnumRoundTrip(t *testing
})
}
}
// stepInterval is a struct-backed Step, so omitempty had no effect and an unset
// value serialized as 0; ,omitzero omits it when unset while still echoing a set
// value (as seconds), keeping the create -> GET round-trip stable.
func TestQueryBuilderQuery_StepInterval_MarshalRoundTrip(t *testing.T) {
testCases := []struct {
name string
query QueryBuilderQuery[LogAggregation]
present []string
absent []string
}{
{
name: "UnsetStepIntervalIsOmitted",
query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs},
absent: []string{`"stepInterval"`},
},
{
name: "SetStepIntervalIsSerializedAsSeconds",
query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs, StepInterval: Step{60 * time.Second}},
present: []string{`"stepInterval":60`},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
expected, err := json.Marshal(testCase.query)
require.NoError(t, err)
for _, fragment := range testCase.present {
assert.Contains(t, string(expected), fragment)
}
for _, fragment := range testCase.absent {
assert.NotContains(t, string(expected), fragment)
}
})
}
}

View File

@@ -130,7 +130,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
{4.0, 5.0, 6.0},
},
},
expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`,
expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`,
},
{
name: "scalar data with NaN",
@@ -149,7 +149,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
{math.Inf(1), 5.0, math.Inf(-1)},
},
},
expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`,
expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`,
},
{
name: "scalar data with mixed types",
@@ -168,7 +168,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
{nil, math.Inf(1), 3.14, false},
},
},
expected: `{"queryName":"test_query","columns":[{"name":"mixed","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`,
expected: `{"queryName":"test_query","columns":[{"name":"mixed","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`,
},
{
name: "scalar data with nested structures",
@@ -189,7 +189,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
},
},
},
expected: `{"queryName":"test_query","columns":[{"name":"nested","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`,
expected: `{"queryName":"test_query","columns":[{"name":"nested","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`,
},
{
name: "empty scalar data",

View File

@@ -51,7 +51,7 @@ type QueryBuilderTraceOperator struct {
Order []OrderBy `json:"order,omitzero"`
Aggregations []TraceAggregation `json:"aggregations,omitzero"`
StepInterval Step `json:"stepInterval,omitzero"`
StepInterval Step `json:"stepInterval,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitzero"`
// having clause to apply to the aggregated query results

View File

@@ -30,16 +30,12 @@ const (
)
type TelemetryFieldKey struct {
Name string `json:"name" validate:"required" required:"true"`
Description string `json:"description,omitempty"`
Unit string `json:"unit,omitempty"`
// signal/fieldContext/fieldDataType always serialize (empty included): the empty
// value is a first-class "unspecified / any" selection a client can set, so it
// must round-trip verbatim rather than be dropped. Their Enum()s include the
// empty member so the "" is a valid schema value.
Signal Signal `json:"signal"`
FieldContext FieldContext `json:"fieldContext"`
FieldDataType FieldDataType `json:"fieldDataType"`
Name string `json:"name" validate:"required" required:"true"`
Description string `json:"description,omitempty"`
Unit string `json:"unit,omitempty"`
Signal Signal `json:"signal,omitzero"`
FieldContext FieldContext `json:"fieldContext,omitzero"`
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
JSONPlan JSONAccessPlan `json:"-"`
Indexes []TelemetryFieldKeySkipIndex `json:"-"`

View File

@@ -185,6 +185,5 @@ func (FieldContext) Enum() []any {
FieldContextAttribute,
// FieldContextEvent,
FieldContextBody,
FieldContextUnspecified,
}
}

View File

@@ -190,7 +190,6 @@ func (FieldDataType) Enum() []any {
FieldDataTypeFloat64,
FieldDataTypeInt64,
FieldDataTypeNumber,
FieldDataTypeUnspecified,
// FieldDataTypeArrayString,
// FieldDataTypeArrayFloat64,
// FieldDataTypeArrayBool,

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