Compare commits

..

8 Commits

Author SHA1 Message Date
Vinicius Lourenço
df77b8d125 fix(settings): ensure scroll on tiny screens (#11916)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-06-30 18:45:47 +00:00
Swapnil Nakade
028ac27496 feat: adding cloud integration API changes for GCP (#11892)
* feat: adding cloud integration API changes for GCP

* chore: generating openapi specs

* fix: integration tests

* ci: fixing golang ci lint
2026-06-30 17:13:53 +00:00
Vinicius Lourenço
8b56f39261 refactor(getting-started): use new invite members component (#11874) 2026-06-30 15:09:54 +00:00
Tushar Vats
2be1063602 feat: QB support for QueryRangePreview (#11185)
* fix: added dry run api

* fix: set nullable for api response fields

* fix: missed adding one file

* fix: comment lint

* fix: finding 1

* fix: moved methods to telemetrystore

* fix: move interface to telemetrystore

* fix: remove wrong flow of imports

* fix: generate openapi

* fix: move explain methods to clickhousetelemetrystore
2026-06-30 14:43:45 +00:00
Vinicius Lourenço
6546602242 refactor(members-page): use new invite members component (#11873)
* refactor(members-page): use new invite members component

* chore(invite-members): move to be under member settings
2026-06-30 14:32:40 +00:00
Vinicius Lourenço
ba49b28c5a refactor(onboarding-questionaire): use new invite members component (#11875) 2026-06-30 14:32:03 +00:00
Vinicius Lourenço
adc3909b71 feat(sso-configuration): change roles selector to allow custom roles (#11894) 2026-06-30 14:31:58 +00:00
Pandey
e00f47c812 feat(web): add sentry, posthog, appcues and pylon settings to web config (#11912)
* feat(web): move sentry dsn and tunnel to runtime web settings

Move the Sentry dsn and tunnel out of build-time Vite injection into the
web.settings config so they are configurable per deployment at runtime via
SIGNOZ_WEB_SETTINGS_SENTRY_DSN and SIGNOZ_WEB_SETTINGS_SENTRY_TUNNEL. The
backend injects them into index.html and the frontend reads them from
window.signozBootData.settings; environment and release stay build-time.

* feat(web): move posthog key, api_host and ui_host to runtime web settings

Move the PostHog project key, api_host and ui_host out of build-time Vite
injection into the web.settings config so they are configurable per
deployment at runtime via SIGNOZ_WEB_SETTINGS_POSTHOG_KEY,
SIGNOZ_WEB_SETTINGS_POSTHOG_API__HOST and SIGNOZ_WEB_SETTINGS_POSTHOG_UI__HOST.
The backend injects them into index.html and the frontend reads them from
window.signozBootData.settings; api_host falls back to
https://us.i.posthog.com when unset.

* feat(web): move appcues app id to runtime web settings

Move the Appcues app id out of build-time Vite injection into the
web.settings config so it is configurable per deployment at runtime via
SIGNOZ_WEB_SETTINGS_APPCUES_APP__ID. The backend injects it into index.html
and the Appcues loader reads it from window.signozBootData.settings.

* feat(web): move pylon app id and identity secret to runtime web settings

Move the Pylon app id and identity secret out of build-time Vite injection
into the web.settings config so they are configurable per deployment at
runtime via SIGNOZ_WEB_SETTINGS_PYLON_APP__ID and
SIGNOZ_WEB_SETTINGS_PYLON_IDENTITY__SECRET. The backend injects them into
index.html and the frontend reads them from window.signozBootData.settings.
This was the last build-time integration value, so the now-unused
createHtmlPlugin is removed.

* chore(web): remove unused TUNNEL_DOMAIN

VITE_TUNNEL_DOMAIN / process.env.TUNNEL_DOMAIN was only referenced by
frontend/src/setupProxy.js, a dead Create-React-App artifact that Vite
never loads. Remove the vite define, the type declaration, the dead
setupProxy.js file, and the CI steps that wrote VITE_TUNNEL_DOMAIN to .env.

* chore(ci): drop build-time VITE_ vars now served at runtime

Sentry dsn/tunnel, posthog key, appcues app id, and pylon app id /
identity secret are now configured at runtime via SIGNOZ_WEB_SETTINGS_*
and no longer baked into the bundle, so the CI steps writing them to
.env at build time are dead. Keep the build-only Sentry sourcemap vars
(auth token, org, project id), VITE_VERSION, VITE_ENVIRONMENT and
VITE_DOCS_BASE_URL.

* chore(web): revert frontend and CI web settings changes

Drop the frontend consumption (AppRoutes, vite config, index.html, env
typings, bootSettings helper, setupProxy) and the CI workflow edits for
the web settings migration; these will be done separately. The backend
web.settings config and the generated schema/types stay.

* refactor(web): make new web settings keys optional

The new web settings keys (sentry dsn/tunnel, posthog key/api_host/
ui_host, appcues app_id, pylon app_id/identity_secret) are not required;
drop required:"true" so they are optional in the generated schema and
types. Only enabled stays required.
2026-06-30 13:26:24 +00:00
114 changed files with 3568 additions and 3123 deletions

View File

@@ -177,9 +177,11 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
gcpCloudProviderModule := implcloudprovider.NewGCPCloudProvider(defStore)
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeGCP: gcpCloudProviderModule,
}
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)

View File

@@ -65,15 +65,31 @@ web:
posthog:
# Whether to enable PostHog in web.
enabled: false
# The PostHog project API key.
key: ""
# The PostHog API host. Defaults to https://us.i.posthog.com when empty.
api_host: ""
# The PostHog UI host. Used when api_host points at a reverse proxy.
ui_host: ""
appcues:
# Whether to enable Appcues in web.
enabled: false
# The Appcues account/app ID.
app_id: ""
sentry:
# Whether to enable Sentry in web.
enabled: false
# The Sentry DSN.
dsn: ""
# The Sentry tunnel URL.
tunnel: ""
pylon:
# Whether to enable Pylon in web.
enabled: false
# The Pylon app ID.
app_id: ""
# The Pylon identity verification secret.
identity_secret: ""
##################### Cache #####################
cache:

View File

@@ -1024,6 +1024,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesAgentReport:
nullable: true
@@ -1169,6 +1171,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
type: object
CloudintegrationtypesCredentials:
properties:
@@ -1199,6 +1203,46 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
type: object
CloudintegrationtypesGCPIntegrationConfig:
type: object
CloudintegrationtypesGCPServiceConfig:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceLogsConfig'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceMetricsConfig'
type: object
CloudintegrationtypesGCPServiceLogsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGCPServiceMetricsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -1331,6 +1375,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -1355,6 +1401,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
type: object
CloudintegrationtypesService:
properties:
@@ -1399,6 +1447,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
type: object
CloudintegrationtypesServiceDashboard:
properties:
@@ -1441,6 +1491,7 @@ components:
- cosmosdb
- cassandradb
- redis
- cloudsql
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1502,6 +1553,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
type: object
CloudintegrationtypesUpdatableAzureAccountConfig:
properties:
@@ -1512,6 +1565,22 @@ components:
required:
- resourceGroups
type: object
CloudintegrationtypesUpdatableGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesUpdatableService:
properties:
config:
@@ -6212,6 +6281,25 @@ components:
- asc
- desc
type: string
Querybuildertypesv5PreviewStatement:
properties:
db.statement.args:
items: {}
type: array
db.statement.query:
type: string
estimate:
items:
$ref: '#/components/schemas/TelemetrystoretypesEstimateEntry'
type: array
granules:
$ref: '#/components/schemas/TelemetrystoretypesGranules'
required:
- db.statement.query
- db.statement.args
- estimate
- granules
type: object
Querybuildertypesv5PromQuery:
properties:
disabled:
@@ -6532,6 +6620,40 @@ components:
required:
- type
type: object
Querybuildertypesv5QueryPreview:
properties:
error: {}
statements:
items:
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
type: array
valid:
type: boolean
warnings:
items:
type: string
type: array
required:
- valid
- error
- warnings
- statements
type: object
Querybuildertypesv5QueryRangePreviewResponse:
description: Response from the v5 query range preview (dry-run) endpoint. For
each query in the composite query, returns the underlying ClickHouse statement(s)
it renders to without executing them (one per PromQL metric selector; exactly
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
ESTIMATE and granule analysis attached per statement when requested.
properties:
compositeQuery:
additionalProperties:
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
nullable: true
type: object
required:
- compositeQuery
type: object
Querybuildertypesv5QueryRangeRequest:
description: Request body for the v5 query range endpoint. Supports builder
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
@@ -7882,6 +8004,96 @@ components:
- key
- value
type: object
TelemetrystoretypesEstimateEntry:
properties:
database:
type: string
marks:
format: int64
type: integer
parts:
format: int64
type: integer
rows:
format: int64
type: integer
table:
type: string
required:
- database
- table
- parts
- rows
- marks
type: object
TelemetrystoretypesGranules:
nullable: true
properties:
initial:
format: int64
type: integer
reads:
items:
$ref: '#/components/schemas/TelemetrystoretypesMergeTreeRead'
type: array
selected:
format: int64
type: integer
skipped:
format: int64
type: integer
required:
- initial
- selected
- skipped
- reads
type: object
TelemetrystoretypesIndexStep:
properties:
condition:
type: string
initialGranules:
format: int64
type: integer
initialParts:
format: int64
type: integer
keys:
items:
type: string
type: array
name:
type: string
selectedGranules:
format: int64
type: integer
selectedParts:
format: int64
type: integer
type:
type: string
required:
- type
- name
- keys
- condition
- initialParts
- selectedParts
- initialGranules
- selectedGranules
type: object
TelemetrystoretypesMergeTreeRead:
properties:
steps:
items:
$ref: '#/components/schemas/TelemetrystoretypesIndexStep'
type: array
table:
type: string
required:
- table
- steps
type: object
TelemetrytypesFieldContext:
enum:
- metric
@@ -23413,6 +23625,75 @@ paths:
summary: Query range
tags:
- querier
/api/v5/query_range/preview:
post:
deprecated: false
description: 'Validate a composite query without executing it. Accepts the same
payload as the query range endpoint. By default (verbose=true) returns, for
each query, the rendered underlying ClickHouse statement(s) with each statement''s
EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving
granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight
per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse
round trips. Intended for agentic/dry-run consumption: per-query errors are
reported in the response rather than failing the whole request.'
operationId: QueryRangePreviewV5
parameters:
- in: query
name: verbose
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Query range preview
tags:
- querier
/api/v5/substitute_vars:
post:
deprecated: false

View File

@@ -0,0 +1,36 @@
package implcloudprovider
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeGCP, serviceID)
}
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}

View File

@@ -101,6 +101,10 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRange(rw, req)
}
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRangePreview(rw, req)
}
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRawStream(rw, req)
}

View File

@@ -12,6 +12,8 @@ import type {
} from 'react-query';
import type {
QueryRangePreviewV5200,
QueryRangePreviewV5Params,
QueryRangeV5200,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
@@ -104,6 +106,107 @@ export const useQueryRangeV5 = <
> => {
return useMutation(getQueryRangeV5MutationOptions(options));
};
/**
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
* @summary Query range preview
*/
export const queryRangePreviewV5 = (
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
params?: QueryRangePreviewV5Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<QueryRangePreviewV5200>({
url: `/api/v5/query_range/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
params,
signal,
});
};
export const getQueryRangePreviewV5MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
const mutationKey = ['queryRangePreviewV5'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
}
> = (props) => {
const { data, params } = props ?? {};
return queryRangePreviewV5(data, params);
};
return { mutationFn, ...mutationOptions };
};
export type QueryRangePreviewV5MutationResult = NonNullable<
Awaited<ReturnType<typeof queryRangePreviewV5>>
>;
export type QueryRangePreviewV5MutationBody =
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
| undefined;
export type QueryRangePreviewV5MutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Query range preview
*/
export const useQueryRangePreviewV5 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
return useMutation(getQueryRangePreviewV5MutationOptions(options));
};
/**
* Replace variables in a query
* @summary Replace variables

View File

@@ -2630,9 +2630,25 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
}
export interface CloudintegrationtypesAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesAccountDTO {
@@ -2740,9 +2756,29 @@ export interface CloudintegrationtypesAzureServiceConfigDTO {
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesGCPServiceLogsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceMetricsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceConfigDTO {
logs?: CloudintegrationtypesGCPServiceLogsConfigDTO;
metrics?: CloudintegrationtypesGCPServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesServiceConfigDTO {
aws?: CloudintegrationtypesAWSServiceConfigDTO;
azure?: CloudintegrationtypesAzureServiceConfigDTO;
gcp?: CloudintegrationtypesGCPServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
@@ -2773,6 +2809,7 @@ export enum CloudintegrationtypesServiceIDDTO {
cosmosdb = 'cosmosdb',
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql = 'cloudsql',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -2837,9 +2874,14 @@ export interface CloudintegrationtypesCollectedMetricDTO {
unit?: string;
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
}
export interface CloudintegrationtypesCredentialsDTO {
@@ -2872,6 +2914,10 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
@@ -2963,6 +3009,7 @@ export type CloudintegrationtypesIntegrationConfigDTO =
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
}
export interface CloudintegrationtypesGettableAgentCheckInDTO {
@@ -3025,6 +3072,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -3154,9 +3202,25 @@ export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array,null
*/
projectIds: string[] | null;
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
gcp?: CloudintegrationtypesUpdatableGCPAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
@@ -7555,6 +7619,126 @@ export interface Querybuildertypesv5FormatOptionsDTO {
formatTableResultForUI?: boolean;
}
export interface TelemetrystoretypesEstimateEntryDTO {
/**
* @type string
*/
database: string;
/**
* @type integer
* @format int64
*/
marks: number;
/**
* @type integer
* @format int64
*/
parts: number;
/**
* @type integer
* @format int64
*/
rows: number;
/**
* @type string
*/
table: string;
}
export interface TelemetrystoretypesIndexStepDTO {
/**
* @type string
*/
condition: string;
/**
* @type integer
* @format int64
*/
initialGranules: number;
/**
* @type integer
* @format int64
*/
initialParts: number;
/**
* @type array
*/
keys: string[];
/**
* @type string
*/
name: string;
/**
* @type integer
* @format int64
*/
selectedGranules: number;
/**
* @type integer
* @format int64
*/
selectedParts: number;
/**
* @type string
*/
type: string;
}
export interface TelemetrystoretypesMergeTreeReadDTO {
/**
* @type array
*/
steps: TelemetrystoretypesIndexStepDTO[];
/**
* @type string
*/
table: string;
}
export type TelemetrystoretypesGranulesDTOAnyOf = {
/**
* @type integer
* @format int64
*/
initial: number;
/**
* @type array
*/
reads: TelemetrystoretypesMergeTreeReadDTO[];
/**
* @type integer
* @format int64
*/
selected: number;
/**
* @type integer
* @format int64
*/
skipped: number;
};
/**
* @nullable
*/
export type TelemetrystoretypesGranulesDTO =
TelemetrystoretypesGranulesDTOAnyOf | null;
export interface Querybuildertypesv5PreviewStatementDTO {
/**
* @type array
*/
'db.statement.args': unknown[];
/**
* @type string
*/
'db.statement.query': string;
/**
* @type array
*/
estimate: TelemetrystoretypesEstimateEntryDTO[];
granules: TelemetrystoretypesGranulesDTO | null;
}
export interface Querybuildertypesv5TimeSeriesDataDTO {
/**
* @type array,null
@@ -7636,6 +7820,41 @@ export type Querybuildertypesv5QueryDataDTO =
results?: unknown[] | null;
});
export interface Querybuildertypesv5QueryPreviewDTO {
error: unknown;
/**
* @type array
*/
statements: Querybuildertypesv5PreviewStatementDTO[];
/**
* @type boolean
*/
valid: boolean;
/**
* @type array
*/
warnings: string[];
}
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
/**
* @nullable
*/
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
/**
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
*/
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
/**
* @type object,null
*/
compositeQuery: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
}
export enum Querybuildertypesv5VariableTypeDTO {
query = 'query',
dynamic = 'dynamic',
@@ -11510,6 +11729,22 @@ export type QueryRangeV5200 = {
status: string;
};
export type QueryRangePreviewV5Params = {
/**
* @type string
* @description undefined
*/
verbose?: string;
};
export type QueryRangePreviewV5200 = {
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
/**
* @type string
*/
status: string;
};
export type ReplaceVariables200 = {
data: Querybuildertypesv5QueryRangeRequestDTO;
/**

View File

@@ -167,6 +167,7 @@ describe('InviteMembers - Submission', () => {
success: false,
}),
]),
expect.any(Array),
);
});
});
@@ -243,6 +244,7 @@ describe('InviteMembers - Submission', () => {
error: 'User already exists',
}),
]),
expect.any(Array),
);
await expect(

View File

@@ -22,9 +22,9 @@ export interface FooterRenderProps {
export interface UseInviteMembersOptions {
initialRowCount?: number;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
}
export interface UseInviteMembersReturn {
@@ -56,9 +56,9 @@ export interface InviteMembersProps {
showHeader?: boolean;
showAddButton?: boolean;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
renderFooter?: (props: FooterRenderProps) => ReactNode;
}

View File

@@ -207,11 +207,11 @@ export function useInviteMembers(
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
onSuccess?.();
onSuccess?.(results, touched);
} else if (successes.length > 0) {
onPartialSuccess?.(results);
onPartialSuccess?.(results, touched);
} else {
onAllFailed?.(results);
onAllFailed?.(results, touched);
}
return results;

View File

@@ -1,254 +0,0 @@
.invite-members-modal {
max-width: 700px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--l1-foreground);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.invite-members-modal__content {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4);
}
}
}
.invite-members-modal__table {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.invite-members-modal__table-header {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
.email-header {
flex: 0 0 240px;
}
.role-header {
flex: 1 0 0;
min-width: 0;
}
.action-header {
flex: 0 0 32px;
}
.table-header-cell {
font-family: Inter, sans-serif;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
color: var(--foreground);
}
}
.invite-members-modal__container {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
}
.team-member-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
width: 100%;
> .email-cell {
flex: 0 0 240px;
}
> .role-cell {
flex: 1 0 0;
min-width: 0;
}
> .action-cell {
flex: 0 0 32px;
}
}
.team-member-cell {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
&.action-cell {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
}
}
.team-member-email-input {
width: 100%;
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--l1-border);
font-size: var(--paragraph-base-400-font-size);
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
.team-member-role-select {
width: 100%;
.ant-select-selector {
height: 32px;
border-radius: 2px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
padding: 0 var(--padding-2) !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground);
opacity: 0.4;
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
line-height: 32px;
}
.ant-select-selection-item {
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
color: var(--l1-foreground);
line-height: 32px;
}
}
.ant-select-arrow {
color: var(--foreground);
}
&.ant-select-focused .ant-select-selector,
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary);
}
}
.remove-team-member-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border: none;
border-radius: 2px;
background: transparent;
color: var(--destructive);
opacity: 0.6;
padding: 0;
transition:
background-color 0.2s,
opacity 0.2s;
box-shadow: none;
&:hover {
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
opacity: 0.9;
}
}
.email-error-message {
display: block;
font-family: Inter, sans-serif;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-18);
color: var(--destructive);
}
.invite-team-members-error-callout {
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--danger-background) 20%, transparent);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
.invite-members-modal__error-callout {
display: flex;
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}
.invite-members-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: 0;
flex-shrink: 0;
}
.invite-members-modal__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.add-another-member-button {
&:hover {
border-color: var(--primary);
border-style: dashed;
color: var(--l1-foreground);
}
}

View File

@@ -1,337 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Style } from '@signozhq/design-tokens';
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { Select } from 'antd';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
import { getBaseUrl } from 'utils/basePath';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './InviteMembersModal.styles.scss';
interface InviteRow {
id: string;
email: string;
role: ROLES | '';
}
export interface InviteMembersModalProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
const isRowTouched = (row: InviteRow): boolean =>
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
function InviteMembersModal({
open,
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [rows, setRows] = useState<InviteRow[]>(() => [
EMPTY_ROW(),
EMPTY_ROW(),
EMPTY_ROW(),
]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const resetAndClose = useCallback((): void => {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
onClose();
}, [onClose]);
useEffect(() => {
if (open) {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
}
}, [open]);
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const validateAllUsers = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touchedRows = rows.filter(isRowTouched);
touchedRows.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = Boolean(row.role && row.role.trim() !== '');
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (row.id) {
updatedEmailValidity[row.id] = emailValid;
}
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = EMAIL_REGEX.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
useEffect(() => {
if (!open) {
debouncedValidateEmail.cancel();
}
return (): void => {
debouncedValidateEmail.cancel();
};
}, [open, debouncedValidateEmail]);
const updateEmail = (id: string, email: string): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.email = email;
setRows(updatedRows);
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
debouncedValidateEmail(email, id);
}
};
const updateRole = (id: string, role: ROLES): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.role = role;
setRows(updatedRows);
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
}
};
const addRow = (): void => {
setRows((prev) => [...prev, EMPTY_ROW()]);
};
const removeRow = (id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
};
const handleSubmit = useCallback(async (): Promise<void> => {
if (!validateAllUsers()) {
return;
}
const touchedRows = rows.filter(isRowTouched);
if (touchedRows.length === 0) {
return;
}
setIsSubmitting(true);
try {
if (touchedRows.length === 1) {
const row = touchedRows[0];
await sendInvite({
email: row.email.trim(),
name: '',
role: row.role as ROLES,
frontendBaseUrl: getBaseUrl(),
});
} else {
await inviteUsers({
invites: touchedRows.map((row) => ({
email: row.email.trim(),
name: '',
role: row.role,
frontendBaseUrl: getBaseUrl(),
})),
});
}
toast.success('Invites sent successfully', { position: 'top-right' });
resetAndClose();
onComplete?.();
} catch (err) {
showErrorModal(err as APIError);
} finally {
setIsSubmitting(false);
}
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
return (
<DialogWrapper
title="Invite Team Members"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
resetAndClose();
}
}}
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick={isErrorModalVisible}
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
<div className="invite-members-modal__table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<div className="invite-members-modal__container">
{rows.map(
(row): JSX.Element => (
<div key={row.id} className="team-member-row">
<div className="team-member-cell email-cell">
<Input
type="email"
placeholder="john@signoz.io"
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
className="team-member-email-input"
name={`invite-email-${row.id}`}
autoComplete="email"
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<span className="email-error-message">Invalid email address</span>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={row.role || undefined}
onChange={(role): void => updateRole(row.id, role as ROLES)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{rows.length > 1 && (
<Button
variant="ghost"
color="destructive"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
),
)}
</div>
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<div className="invite-members-modal__error-callout">
<Callout
type="error"
size="small"
showIcon
title={getValidationErrorMessage()}
/>
</div>
)}
</div>
<DialogFooter className="invite-members-modal__footer">
<Button
variant="dashed"
color="secondary"
className="add-another-member-button"
prefix={<Plus size={12} color={Style.L1_FOREGROUND} />}
onClick={addRow}
>
Add another
</Button>
<div className="invite-members-modal__footer-right">
<Button
type="button"
variant="solid"
color="secondary"
onClick={resetAndClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSubmit}
disabled={isSubmitDisabled}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>
</div>
</DialogFooter>
</DialogWrapper>
);
}
export default InviteMembersModal;

View File

@@ -1,276 +0,0 @@
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { StatusCodes } from 'http-status-codes';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import InviteMembersModal from '../InviteMembersModal';
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
new APIError({
httpStatusCode: code,
error: { code: 'already_exists', message, url: '', errors: [] },
});
jest.mock('api/v1/invite/create');
jest.mock('api/v1/invite/bulk/create');
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockSendInvite = jest.mocked(sendInvite);
const mockInviteUsers = jest.mocked(inviteUsers);
const defaultProps = {
open: true,
onClose: jest.fn(),
onComplete: jest.fn(),
};
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
mockSendInvite.mockResolvedValue({
httpStatusCode: 200,
data: { data: 'test', status: 'success' },
});
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
});
it('renders 3 initial empty rows and disables the submit button', () => {
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
expect(emailInputs).toHaveLength(3);
expect(
screen.getByRole('button', { name: /invite team members/i }),
).toBeDisabled();
});
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
});
describe('validation callout messages', () => {
it('shows combined message when email is invalid and role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'not-an-email',
);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await expect(
screen.findByText(
'Please enter valid emails and select roles for team members',
),
).resolves.toBeInTheDocument();
});
it('shows email-only message when email is invalid but role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'not-an-email');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
});
it('shows role-only message when email is valid but role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'valid@signoz.io',
);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
});
});
it('uses sendInvite (single) when only one row is filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'single@signoz.io');
const roleSelects = screen.getAllByText('Select roles');
await user.click(roleSelects[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(mockSendInvite).toHaveBeenCalledWith(
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
);
expect(mockInviteUsers).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
describe('error handling', () => {
it('shows BE message on single invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: single@signoz.io',
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
it('shows BE message on bulk invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: alice@signoz.io',
);
mockInviteUsers.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
it('shows BE message on generic error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'Internal server error',
StatusCodes.INTERNAL_SERVER_ERROR,
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
});
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(mockInviteUsers).toHaveBeenCalledWith({
invites: expect.arrayContaining([
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
]),
});
expect(mockSendInvite).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
});

View File

@@ -3,7 +3,6 @@
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}

View File

@@ -32,10 +32,13 @@ export function useRoles(): {
};
}
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
export function getRoleOptions(
roles: AuthtypesRoleDTO[],
valueField: 'id' | 'name',
): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role.id ?? '',
value: role[valueField] ?? '',
}));
}
@@ -82,6 +85,7 @@ interface BaseProps {
error?: APIError;
onRefetch?: () => void;
disabled?: boolean;
valueField?: 'id' | 'name';
}
interface SingleProps extends BaseProps {
@@ -113,7 +117,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
});
const roles = externalRoles ?? data?.data ?? [];
const options = getRoleOptions(roles);
const options = getRoleOptions(roles, props.valueField || 'id');
const {
mode,

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { SolidAlertTriangle } from '@signozhq/icons';
import { Select, Tooltip } from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import classNames from 'classnames';
import { UniversalYAxisUnitMappings } from './constants';
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
@@ -72,7 +72,9 @@ function YAxisUnitSelector({
}, [categoriesOverride, source]);
return (
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
<div
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<Select
showSearch
value={universalUnit}
@@ -82,17 +84,12 @@ function YAxisUnitSelector({
loading={loading}
suffixIcon={
incompatibleUnitMessage ? (
<Tooltip
title={incompatibleUnitMessage}
overlayClassName="y-axis-unit-warning-tooltip"
>
<span className="y-axis-unit-warning" role="img" aria-label="warning">
<SolidAlertTriangle size="md" />
</span>
<Tooltip title={incompatibleUnitMessage}>
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
</Tooltip>
) : undefined
}
className={cx({
className={classNames({
'warning-state': incompatibleUnitMessage,
})}
data-testid={dataTestId}

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisSource } from '../types';
@@ -7,13 +6,9 @@ import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => {
const mockOnChange = jest.fn();
// antd injects its `pointer-events` styles via cssinjs in jsdom, but the SCSS
// overrides aren't loaded — skip the pointer-events check so hovers/clicks register.
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
mockOnChange.mockClear();
user = userEvent.setup({ pointerEventsCheck: 0 });
});
it('renders with default placeholder', () => {
@@ -39,7 +34,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
});
it('calls onChange when a value is selected', async () => {
it('calls onChange when a value is selected', () => {
render(
<YAxisUnitSelector
value=""
@@ -49,8 +44,9 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
await user.click(select);
await user.click(screen.getByText('Bytes (B)'));
fireEvent.mouseDown(select);
const option = screen.getByText('Bytes (B)');
fireEvent.click(option);
expect(mockOnChange).toHaveBeenCalledWith('By', {
children: 'Bytes (B)',
@@ -59,7 +55,7 @@ describe('YAxisUnitSelector', () => {
});
});
it('filters options based on search input', async () => {
it('filters options based on search input', () => {
render(
<YAxisUnitSelector
value=""
@@ -69,13 +65,14 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
await user.click(select);
await user.type(select, 'bytes/sec');
fireEvent.mouseDown(select);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'bytes/sec' } });
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
});
it('shows all categories and their units', async () => {
it('shows all categories and their units', () => {
render(
<YAxisUnitSelector
value=""
@@ -83,8 +80,9 @@ describe('YAxisUnitSelector', () => {
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
await user.click(screen.getByRole('combobox'));
fireEvent.mouseDown(select);
// Check for category headers
expect(screen.getByText('Data')).toBeInTheDocument();
@@ -95,7 +93,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
});
it('shows warning message when incompatible unit is selected', async () => {
it('shows warning message when incompatible unit is selected', () => {
render(
<YAxisUnitSelector
source={YAxisSource.ALERTS}
@@ -106,12 +104,12 @@ describe('YAxisUnitSelector', () => {
);
const warningIcon = screen.getByLabelText('warning');
expect(warningIcon).toBeInTheDocument();
await user.hover(warningIcon);
await expect(
screen.findByText(
fireEvent.mouseOver(warningIcon);
return screen
.findByText(
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
),
).resolves.toBeInTheDocument();
)
.then((el) => expect(el).toBeInTheDocument());
});
it('does not show warning message when compatible unit is selected', () => {
@@ -127,7 +125,7 @@ describe('YAxisUnitSelector', () => {
expect(warningIcon).not.toBeInTheDocument();
});
it('uses categories override to render custom units', async () => {
it('uses categories override to render custom units', () => {
const customCategories = [
{
name: YAxisCategoryNames.Data,
@@ -149,7 +147,9 @@ describe('YAxisUnitSelector', () => {
/>,
);
await user.click(screen.getByRole('combobox'));
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();

View File

@@ -4,13 +4,6 @@
}
}
// Re-enable hover on the warning icon: its `.ant-select-arrow` parent sets
// `pointer-events: none`, which would otherwise suppress the tooltip.
.y-axis-unit-warning {
display: inline-flex;
pointer-events: auto;
}
.warning-state {
.ant-select-selector {
border-color: var(--bg-amber-400) !important;
@@ -24,7 +17,3 @@
right: 28px;
}
}
.y-axis-unit-warning-tooltip {
max-width: 240px;
}

View File

@@ -1,9 +1,14 @@
@use '../../styles/scrollbar' as *;
.members-settings-page {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
overflow-y: auto;
@include custom-scrollbar;
}
.members-settings {

View File

@@ -6,7 +6,7 @@ import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Input } from '@signozhq/ui/input';
import { useListUsers } from 'api/generated/services/users';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import InviteMembersModal from 'container/MembersSettings/components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { parseAsBoolean, useQueryState } from 'nuqs';

View File

@@ -110,7 +110,9 @@ describe('MembersSettings (integration)', () => {
fireEvent.click(await screen.findByText('Alice Smith'));
await screen.findByText('Member Details');
await expect(
screen.findByText('Member Details'),
).resolves.toBeInTheDocument();
});
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
@@ -127,7 +129,7 @@ describe('MembersSettings (integration)', () => {
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
await expect(
screen.findAllByPlaceholderText('john@signoz.io'),
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
).resolves.toHaveLength(3);
});
@@ -137,7 +139,7 @@ describe('MembersSettings (integration)', () => {
});
await expect(
screen.findAllByPlaceholderText('john@signoz.io'),
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
).resolves.toHaveLength(3);
});
});

View File

@@ -0,0 +1,38 @@
.invite-members-modal {
max-width: 700px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--l1-foreground);
margin: 0;
}
[data-slot='dialog-description'] {
padding: var(--padding-4);
}
}
.invite-members-modal__footer {
padding-top: var(--padding-4);
border-top: 1px solid var(--l1-border);
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-6);
}

View File

@@ -0,0 +1,71 @@
import { useCallback } from 'react';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import InviteMembers from 'components/InviteMembers/InviteMembers';
import './InviteMembersModal.styles.scss';
export interface InviteMembersModalProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
function InviteMembersModal({
open,
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const handleSuccess = useCallback((): void => {
toast.success('Invites sent successfully', { position: 'top-right' });
onClose();
onComplete?.();
}, [onClose, onComplete]);
const handlePartialSuccess = useCallback((): void => {
toast.warning('Some invites failed', { position: 'top-right' });
onComplete?.();
}, [onComplete]);
return (
<DialogWrapper
title="Invite Team Members"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
showCloseButton
width="wide"
className="invite-members-modal"
>
<InviteMembers
onSuccess={handleSuccess}
onPartialSuccess={handlePartialSuccess}
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
<div className="invite-members-modal__footer">
<Button type="button" variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={submit}
disabled={!canSubmit}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>
</div>
)}
/>
</DialogWrapper>
);
}
export default InviteMembersModal;

View File

@@ -0,0 +1,210 @@
import { toast } from '@signozhq/ui/sonner';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteMembersModal from '../InviteMembersModal';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
warning: jest.fn(),
},
}));
interface MockInviteMembersProps {
onSuccess: () => void;
onPartialSuccess: () => void;
onAllFailed?: () => void;
renderFooter: (props: {
submit: () => void;
canSubmit: boolean;
isSubmitting: boolean;
}) => JSX.Element;
}
let mockInviteMembersProps: MockInviteMembersProps | null = null;
jest.mock('components/InviteMembers/InviteMembers', () => {
return function MockInviteMembers(props: MockInviteMembersProps): JSX.Element {
mockInviteMembersProps = props;
return (
<div data-testid="mock-invite-members">
{props.renderFooter({
submit: jest.fn(),
canSubmit: true,
isSubmitting: false,
})}
</div>
);
};
});
const defaultProps = {
open: true,
onClose: jest.fn(),
onComplete: jest.fn(),
};
function renderComponent(
props: Partial<typeof defaultProps> = {},
): ReturnType<typeof render> {
return render(<InviteMembersModal {...defaultProps} {...props} />);
}
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInviteMembersProps = null;
});
describe('rendering', () => {
it('renders modal with title and InviteMembers component', () => {
renderComponent();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Invite Team Members',
);
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
});
it('does not render when open=false', () => {
renderComponent({ open: false });
expect(screen.queryByText('Invite Team Members')).not.toBeInTheDocument();
});
});
describe('footer buttons', () => {
it('renders Cancel and Invite buttons', () => {
renderComponent();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /invite team members/i }),
).toBeInTheDocument();
});
it('disables Invite button when canSubmit=false', () => {
const { unmount } = renderComponent();
unmount();
const { getByRole } = render(
mockInviteMembersProps?.renderFooter({
submit: jest.fn(),
canSubmit: false,
isSubmitting: false,
}) as JSX.Element,
);
expect(getByRole('button', { name: /invite team members/i })).toBeDisabled();
});
it('shows loading state when isSubmitting=true', () => {
const { unmount } = renderComponent();
unmount();
const { getByRole } = render(
mockInviteMembersProps?.renderFooter({
submit: jest.fn(),
canSubmit: true,
isSubmitting: true,
}) as JSX.Element,
);
expect(getByRole('button', { name: /inviting/i })).toBeInTheDocument();
});
it('calls onClose when Cancel is clicked', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderComponent({ onClose });
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls submit when Invite button is clicked', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
const { unmount } = renderComponent();
unmount();
const { getByRole } = render(
mockInviteMembersProps?.renderFooter({
submit: mockSubmit,
canSubmit: true,
isSubmitting: false,
}) as JSX.Element,
);
await user.click(getByRole('button', { name: /invite team members/i }));
expect(mockSubmit).toHaveBeenCalledTimes(1);
});
});
describe('handleSuccess callback', () => {
it('shows success toast, calls onClose and onComplete', () => {
const onClose = jest.fn();
const onComplete = jest.fn();
renderComponent({ onClose, onComplete });
mockInviteMembersProps?.onSuccess();
expect(toast.success).toHaveBeenCalledWith('Invites sent successfully', {
position: 'top-right',
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('works without onComplete prop', () => {
const onClose = jest.fn();
renderComponent({ onClose, onComplete: undefined });
mockInviteMembersProps?.onSuccess();
expect(toast.success).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('handlePartialSuccess callback', () => {
it('shows warning toast and calls onComplete', () => {
const onComplete = jest.fn();
renderComponent({ onComplete });
mockInviteMembersProps?.onPartialSuccess();
expect(toast.warning).toHaveBeenCalledWith('Some invites failed', {
position: 'top-right',
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('does not call onClose on partial success', () => {
const onClose = jest.fn();
renderComponent({ onClose });
mockInviteMembersProps?.onPartialSuccess();
expect(onClose).not.toHaveBeenCalled();
});
});
describe('dialog close behavior', () => {
it('calls onClose when dialog is closed via close button', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderComponent({ onClose });
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
});
});

View File

@@ -1,26 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { useMemo } from 'react';
import { ArrowRight, LoaderCircle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Input } from '@signozhq/ui/input';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import AuthError from 'components/AuthError/AuthError';
import InviteMembers from 'components/InviteMembers/InviteMembers';
import { InviteMemberRow, InviteResult } from 'components/InviteMembers/types';
import { useRoles } from 'components/RolesSelect/RolesSelect';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce } from 'lodash-es';
import {
ArrowRight,
ChevronDown,
CircleAlert,
LoaderCircle,
Plus,
Trash2,
} from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
@@ -36,101 +22,41 @@ interface TeamMember {
interface InviteTeamMembersProps {
isLoading: boolean;
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
}
function InviteTeamMembers({
isLoading,
teamMembers,
setTeamMembers,
onNext,
}: InviteTeamMembersProps): JSX.Element {
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
TeamMember[] | null
>(teamMembers);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const [inviteError, setInviteError] = useState<APIError | null>(null);
const { notifications } = useNotifications();
const { roles } = useRoles();
const defaultTeamMember: TeamMember = {
email: '',
role: '',
name: '',
frontendBaseUrl: getBaseUrl(),
id: '',
};
useEffect(() => {
if (teamMembers === null) {
const initialTeamMembers = Array.from({ length: 3 }, () => ({
...defaultTeamMember,
id: uuid(),
}));
setTeamMembersToInvite(initialTeamMembers);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamMembers]);
const handleAddTeamMember = (): void => {
const newTeamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
};
const handleRemoveTeamMember = (id: string): void => {
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
};
const isMemberTouched = (member: TeamMember): boolean =>
member.email.trim() !== '' ||
Boolean(member.role && member.role.trim() !== '');
const validateAllUsers = (): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touchedMembers = teamMembersToInvite?.filter(isMemberTouched) ?? [];
touchedMembers?.forEach((member) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
const roleValid = Boolean(member.role && member.role.trim() !== '');
if (!emailValid || !member.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (member.id) {
updatedEmailValidity[member.id] = emailValid;
const roleIdToName = useMemo(() => {
const map: Record<string, string> = {};
roles.forEach((role) => {
if (role.id && role.name) {
map[role.id] = role.name;
}
});
return map;
}, [roles]);
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
const toTeamMembers = (rows: InviteMemberRow[]): TeamMember[] =>
rows.map((row) => ({
email: row.email,
role: roleIdToName[row.roleId] ?? row.roleId,
name: '',
frontendBaseUrl: getBaseUrl(),
id: row.id,
}));
return isValid;
};
const handleInviteUsersSuccess = (): void => {
const handleSuccess = (
_results: InviteResult[],
rows: InviteMemberRow[],
): void => {
logEvent('Org Onboarding: Invite Team Members Success', {
teamMembers: teamMembersToInvite,
teamMembers: toTeamMembers(rows),
});
notifications.success({
message: 'Invites sent successfully!',
@@ -140,125 +66,34 @@ function InviteTeamMembers({
}, 1000);
};
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
inviteUsers,
{
onSuccess: (): void => {
handleInviteUsersSuccess();
},
onError: (error: APIError): void => {
logEvent('Org Onboarding: Invite Team Members Failed', {
teamMembers: teamMembersToInvite,
});
setInviteError(error);
},
},
);
const handleNext = (): void => {
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite?.filter(isMemberTouched) ?? []);
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteError(null);
sendInvites({
invites: teamMembersToInvite?.filter(isMemberTouched) ?? [],
});
}
const handlePartialSuccess = (
_results: InviteResult[],
rows: InviteMemberRow[],
): void => {
logEvent('Org Onboarding: Invite Team Members Partial Success', {
teamMembers: toTeamMembers(rows),
});
notifications.warning({
message: 'Some invites failed. Check the errors above.',
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedValidateEmail = useCallback(
debounce((email: string, memberId: string, updatedMembers: TeamMember[]) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
// Clear hasInvalidEmails only when ALL emails are valid
if (hasInvalidEmails) {
const allEmailsValid = updatedMembers.every(
(m) => m.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.email),
);
if (allEmailsValid) {
setHasInvalidEmails(false);
}
}
}, 500),
[hasInvalidEmails],
);
const handleEmailChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, member: TeamMember): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate && member.id) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id, updatedMembers);
// Clear API error when user starts typing
if (inviteError) {
setInviteError(null);
}
}
},
[debouncedValidateEmail, inviteError, teamMembersToInvite],
);
const createEmailChangeHandler = useCallback(
(member: TeamMember) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
handleEmailChange(e, member);
},
[handleEmailChange],
);
const handleRoleChange = (role: string, member: TeamMember): void => {
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate && member.id) {
memberToUpdate.role = role;
setTeamMembersToInvite(updatedMembers);
// Clear errors when user selects a role
if (hasInvalidRoles) {
// Check if all roles are now valid
const allRolesValid = updatedMembers.every(
(m) => m.role && m.role.trim() !== '',
);
if (allRolesValid) {
setHasInvalidRoles(false);
}
}
if (inviteError) {
setInviteError(null);
}
}
};
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
const handleAllFailed = (
_results: InviteResult[],
rows: InviteMemberRow[],
): void => {
logEvent('Org Onboarding: Invite Team Members Failed', {
teamMembers: toTeamMembers(rows),
});
};
const handleDoLater = (): void => {
logEvent('Org Onboarding: Clicked Do Later', {
currentPageID: 4,
});
onNext();
};
const hasInvites =
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
const isButtonDisabled = isSendingInvites || isLoading;
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
return (
<div className="questions-container">
<OnboardingQuestionHeader
@@ -273,126 +108,52 @@ function InviteTeamMembers({
Invite your team to the SigNoz workspace
</div>
<div className="invite-team-members-table">
<div className="invite-team-members-table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<InviteMembers
onSuccess={handleSuccess}
onPartialSuccess={handlePartialSuccess}
onAllFailed={handleAllFailed}
showHeader
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => {
const isButtonDisabled = isSubmitting || isLoading;
const isInviteButtonDisabled = isButtonDisabled || !canSubmit;
<div className="invite-team-members-container">
{teamMembersToInvite?.map((member) => (
<div className="team-member-row" key={member.id}>
<div className="team-member-cell email-cell">
<Input
placeholder="e.g. john@signoz.io"
value={member.email}
type="email"
id={`email-input-${member.id}`}
name={`email-input-${member.id}`}
required
autoComplete="off"
className="team-member-email-input"
onChange={createEmailChangeHandler(member)}
/>
{member.id &&
emailValidity[member.id] === false &&
member.email.trim() !== '' && (
<Typography.Text className="email-error-message">
Invalid email address
</Typography.Text>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={member.role || undefined}
onChange={(value): void => handleRoleChange(value, member)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{teamMembersToInvite && teamMembersToInvite.length > 1 && (
<Button
variant="ghost"
color="secondary"
className="remove-team-member-button"
onClick={(): void => handleRemoveTeamMember(member.id)}
aria-label="Remove team member"
>
<Trash2 size={12} />
</Button>
)}
</div>
return (
<div className="onboarding-buttons-container">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${
isInviteButtonDisabled ? 'disabled' : ''
}`}
onClick={submit}
disabled={isInviteButtonDisabled}
data-testid="send-invites-button"
suffix={
isButtonDisabled ? (
<LoaderCircle className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Send Invites
</Button>
<Button
variant="ghost"
color="secondary"
className="onboarding-do-later-button"
onClick={handleDoLater}
disabled={isButtonDisabled}
data-testid="do-later-button"
>
I&apos;ll do this later
</Button>
</div>
))}
</div>
<div className="invite-team-members-add-another-member-container">
<Button
variant="dashed"
color="secondary"
className="add-another-member-button"
prefix={<Plus size={12} />}
onClick={handleAddTeamMember}
>
Add another
</Button>
</div>
</div>
);
}}
/>
</div>
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="invite-team-members-error-callout"
>
{getValidationErrorMessage()}
</Callout>
)}
{inviteError && !hasInvalidEmails && !hasInvalidRoles && (
<AuthError error={inviteError} />
)}
<div className="onboarding-buttons-container">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${
isInviteButtonDisabled ? 'disabled' : ''
}`}
onClick={handleNext}
disabled={isInviteButtonDisabled}
suffix={
isButtonDisabled ? (
<LoaderCircle className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Send Invites
</Button>
<Button
variant="ghost"
color="secondary"
className="onboarding-do-later-button"
onClick={handleDoLater}
disabled={isButtonDisabled}
>
I&apos;ll do this later
</Button>
</div>
</div>
</div>
);

View File

@@ -1,97 +1,86 @@
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
InviteMemberRow,
InviteMembersProps,
InviteResult,
} from 'components/InviteMembers/types';
import logEvent from 'api/common/logEvent';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteTeamMembers from '../InviteTeamMembers';
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
(args: { message: string }) => void
>;
const mockNotificationError = jest.fn() as jest.MockedFunction<
(args: { message: string }) => void
>;
const mockNotificationSuccess = jest.fn();
const mockNotificationWarning = jest.fn();
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
success: mockNotificationSuccess,
error: mockNotificationError,
warning: mockNotificationWarning,
},
}),
}));
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk';
jest.mock('api/common/logEvent', () => jest.fn());
interface TeamMember {
email: string;
role: string;
name: string;
frontendBaseUrl: string;
id: string;
}
jest.mock('components/RolesSelect/RolesSelect', () => ({
useRoles: (): any => ({
roles: [
{ id: 'role-viewer-id', name: 'VIEWER' },
{ id: 'role-editor-id', name: 'EDITOR' },
{ id: 'role-admin-id', name: 'ADMIN' },
],
isLoading: false,
isError: false,
error: undefined,
refetch: jest.fn(),
}),
}));
interface InviteRequestBody {
invites: { email: string; role: string }[];
}
jest.mock('utils/basePath', () => ({
...jest.requireActual('utils/basePath'),
getBaseUrl: (): string => 'http://localhost:3301',
}));
interface RenderProps {
isLoading?: boolean;
teamMembers?: TeamMember[] | null;
}
let mockInviteMembersProps: InviteMembersProps | null = null;
const mockOnNext = jest.fn() as jest.MockedFunction<() => void>;
const mockSetTeamMembers = jest.fn() as jest.MockedFunction<
(members: TeamMember[]) => void
>;
jest.mock('components/InviteMembers/InviteMembers', () => {
return function MockInviteMembers(props: InviteMembersProps): JSX.Element {
mockInviteMembersProps = props;
return (
<div data-testid="mock-invite-members">
{props.renderFooter?.({
submit: jest.fn().mockResolvedValue([]),
reset: jest.fn(),
canSubmit: true,
isSubmitting: false,
touchedCount: 0,
})}
</div>
);
};
});
const mockOnNext = jest.fn();
function renderComponent({
isLoading = false,
teamMembers = null,
}: RenderProps = {}): ReturnType<typeof render> {
return render(
<InviteTeamMembers
isLoading={isLoading}
teamMembers={teamMembers}
setTeamMembers={mockSetTeamMembers}
onNext={mockOnNext}
/>,
);
}
async function selectRole(
user: ReturnType<typeof userEvent.setup>,
selectIndex: number,
optionLabel: string,
): Promise<void> {
const placeholders = screen.getAllByText(/select roles/i);
await user.click(placeholders[selectIndex]);
const optionContent = await screen.findByText(optionLabel);
fireEvent.click(optionContent);
}: { isLoading?: boolean } = {}): ReturnType<typeof render> {
return render(<InviteTeamMembers isLoading={isLoading} onNext={mockOnNext} />);
}
describe('InviteTeamMembers', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
jest.useFakeTimers();
mockInviteMembersProps = null;
});
afterEach(() => {
jest.useRealTimers();
server.resetHandlers();
});
describe('Initial rendering', () => {
it('renders the page header, column labels, default rows, and action buttons', () => {
describe('rendering', () => {
it('renders header and InviteMembers component', () => {
renderComponent();
expect(
@@ -100,11 +89,20 @@ describe('InviteTeamMembers', () => {
expect(
screen.getByText(/signoz is a lot more useful with collaborators/i),
).toBeInTheDocument();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(3);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
});
it('passes showHeader=true to InviteMembers', () => {
renderComponent();
expect(mockInviteMembersProps?.showHeader).toBe(true);
});
});
describe('footer buttons', () => {
it('renders Send Invites and Do Later buttons', () => {
renderComponent();
expect(
screen.getByRole('button', { name: /send invites/i }),
).toBeInTheDocument();
@@ -113,7 +111,7 @@ describe('InviteTeamMembers', () => {
).toBeInTheDocument();
});
it('disables both action buttons while isLoading is true', () => {
it('disables buttons when isLoading=true', () => {
renderComponent({ isLoading: true });
expect(screen.getByRole('button', { name: /send invites/i })).toBeDisabled();
@@ -121,355 +119,181 @@ describe('InviteTeamMembers', () => {
screen.getByRole('button', { name: /i'll do this later/i }),
).toBeDisabled();
});
});
describe('Row management', () => {
it('adds a new empty row when "Add another" is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
it('disables Send Invites when canSubmit=false from InviteMembers', () => {
const { unmount } = renderComponent();
unmount();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(3);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(4);
});
it('removes the correct row when its trash icon is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const emailInputs = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(emailInputs[0], 'first@example.com');
await screen.findByDisplayValue('first@example.com');
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
const { getByTestId } = render(
mockInviteMembersProps?.renderFooter?.({
submit: jest.fn().mockResolvedValue([]),
reset: jest.fn(),
canSubmit: false,
isSubmitting: false,
touchedCount: 0,
}) as JSX.Element,
);
await waitFor(() => {
expect(
screen.queryByDisplayValue('first@example.com'),
).not.toBeInTheDocument();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(2);
});
expect(getByTestId('send-invites-button')).toBeDisabled();
expect(getByTestId('do-later-button')).not.toBeDisabled();
});
it('hides remove buttons when only one row remains', async () => {
renderComponent();
const user = userEvent.setup({ pointerEventsCheck: 0 });
it('disables buttons when isSubmitting=true from InviteMembers', () => {
const { unmount } = renderComponent();
unmount();
let removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
while (removeButtons.length > 0) {
await user.click(removeButtons[0]);
removeButtons = screen.queryAllByRole('button', {
name: /remove team member/i,
});
}
const { getByTestId } = render(
mockInviteMembersProps?.renderFooter?.({
submit: jest.fn().mockResolvedValue([]),
reset: jest.fn(),
canSubmit: true,
isSubmitting: true,
touchedCount: 0,
}) as JSX.Element,
);
expect(
screen.queryByRole('button', { name: /remove team member/i }),
).not.toBeInTheDocument();
expect(getByTestId('send-invites-button')).toBeDisabled();
expect(getByTestId('do-later-button')).toBeDisabled();
});
});
describe('Inline email validation', () => {
it('shows an inline error after typing an invalid email and clears it when a valid email is entered', async () => {
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
});
describe('handleSuccess callback', () => {
it('logs event with teamMembers in correct shape, shows success notification, and calls onNext after delay', () => {
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
const mockResults: InviteResult[] = [
{ email: 'user1@test.com', success: true },
{ email: 'user2@test.com', success: true },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-editor-id' },
];
mockInviteMembersProps?.onSuccess?.(mockResults, mockRows);
await user.type(firstInput, 'not-an-email');
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'good@example.com');
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(
screen.queryByText(/invalid email address/i),
).not.toBeInTheDocument();
});
});
it('does not show an inline error when the field is cleared back to empty', async () => {
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
});
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'a');
await user.clear(firstInput);
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(
screen.queryByText(/invalid email address/i),
).not.toBeInTheDocument();
});
});
});
describe('Validation callout on Complete', () => {
it('shows the correct callout message for each combination of email/role validity', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderComponent();
const removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
await user.click(removeButtons[0]);
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
);
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'bad-email');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(
/please enter valid emails and select roles for team members/i,
),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await selectRole(user, 0, 'Viewer');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(/please enter valid emails for team members/i),
).toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'valid@example.com');
await user.click(screen.getByRole('button', { name: /add another/i }));
const allInputs = screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i);
await user.type(allInputs[1], 'norole@example.com');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(/please select roles for team members/i),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
});
}, 15000);
it('treats whitespace as untouched, clears the callout on fix-and-resubmit, and clears role error on role select', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderComponent();
const removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
await user.click(removeButtons[0]);
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
);
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, ' ');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.queryByText(/please enter valid emails/i),
).not.toBeInTheDocument();
expect(screen.queryByText(/please select roles/i)).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'bad-email');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(
/please enter valid emails and select roles for team members/i,
),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'good@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await waitFor(() => expect(mockOnNext).toHaveBeenCalledTimes(1), {
timeout: 1200,
});
}, 15000);
it('disables the Send Invites button when all rows are untouched (empty)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const sendInvitesBtn = screen.getByRole('button', { name: /send invites/i });
expect(sendInvitesBtn).toBeDisabled();
// Type something to make a row touched
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'a');
expect(sendInvitesBtn).not.toBeDisabled();
});
});
describe('API integration', () => {
it('only sends touched (non-empty) rows — empty rows are excluded from the invite payload', async () => {
let capturedBody: InviteRequestBody | null = null;
server.use(
rest.post(INVITE_USERS_ENDPOINT, async (req, res, ctx) => {
capturedBody = await req.json<InviteRequestBody>();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'only@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(capturedBody).not.toBeNull();
expect(capturedBody?.invites).toHaveLength(1);
expect(capturedBody?.invites[0]).toMatchObject({
email: 'only@example.com',
role: 'ADMIN',
});
});
await waitFor(() => expect(mockOnNext).toHaveBeenCalled(), {
timeout: 1200,
});
});
it('calls the invite API, shows a success notification, and calls onNext after the 1 s delay', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'alice@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(mockNotificationSuccess).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Invites sent successfully!' }),
);
});
await waitFor(
() => {
expect(mockOnNext).toHaveBeenCalledTimes(1);
expect(logEvent).toHaveBeenCalledWith(
'Org Onboarding: Invite Team Members Success',
{
teamMembers: [
{
email: 'user1@test.com',
role: 'VIEWER',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'EDITOR',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
},
],
},
{ timeout: 1200 },
);
expect(mockNotificationSuccess).toHaveBeenCalledWith({
message: 'Invites sent successfully!',
});
expect(mockOnNext).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(mockOnNext).toHaveBeenCalledTimes(1);
});
});
it('renders an API error container when the invite request fails', async () => {
server.use(
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
errors: [{ code: 'INTERNAL_ERROR', msg: 'Something went wrong' }],
}),
),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
describe('handlePartialSuccess callback', () => {
it('logs event with teamMembers in correct shape and shows warning notification', () => {
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
const mockResults: InviteResult[] = [
{ email: 'user1@test.com', success: true },
{ email: 'user2@test.com', success: false, error: 'Already exists' },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-admin-id' },
];
mockInviteMembersProps?.onPartialSuccess?.(mockResults, mockRows);
expect(logEvent).toHaveBeenCalledWith(
'Org Onboarding: Invite Team Members Partial Success',
{
teamMembers: [
{
email: 'user1@test.com',
role: 'VIEWER',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'ADMIN',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
},
],
},
);
await user.type(firstInput, 'fail@example.com');
await selectRole(user, 0, 'Viewer');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(document.querySelector('.auth-error-container')).toBeInTheDocument();
expect(mockNotificationWarning).toHaveBeenCalledWith({
message: 'Some invites failed. Check the errors above.',
});
});
});
await user.type(firstInput, 'x');
await waitFor(() => {
expect(
document.querySelector('.auth-error-container'),
).not.toBeInTheDocument();
describe('handleAllFailed callback', () => {
it('logs event with teamMembers in correct shape', () => {
renderComponent();
const mockResults: InviteResult[] = [
{ email: 'user1@test.com', success: false, error: 'Error 1' },
{ email: 'user2@test.com', success: false, error: 'Error 2' },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-editor-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-viewer-id' },
];
mockInviteMembersProps?.onAllFailed?.(mockResults, mockRows);
expect(logEvent).toHaveBeenCalledWith(
'Org Onboarding: Invite Team Members Failed',
{
teamMembers: [
{
email: 'user1@test.com',
role: 'EDITOR',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'VIEWER',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
},
],
},
);
});
});
describe('handleDoLater', () => {
it('logs event and calls onNext immediately', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
renderComponent();
await user.click(
screen.getByRole('button', { name: /i'll do this later/i }),
);
expect(logEvent).toHaveBeenCalledWith('Org Onboarding: Clicked Do Later', {
currentPageID: 4,
});
expect(mockOnNext).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -143,6 +143,8 @@
}
&.invite-team-members-form {
--invite-members-field-background: var(--l3-background);
padding-right: 12px;
.form-group {

View File

@@ -22,7 +22,8 @@ const ORG_PREFERENCES_ENDPOINT = '*/api/v1/org/preferences/list';
const UPDATE_ORG_PREFERENCE_ENDPOINT = '*/api/v1/org/preferences/name/update';
const UPDATE_PROFILE_ENDPOINT = '*/api/v2/zeus/profiles';
const EDIT_ORG_ENDPOINT = '*/api/v2/orgs/me';
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk/create';
const CREATE_USER_ENDPOINT = '*/api/v2/users';
const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
const mockOrgPreferences = {
data: {
@@ -31,6 +32,12 @@ const mockOrgPreferences = {
status: 'success',
};
const MOCK_ROLES = [
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
];
describe('OnboardingQuestionaire Component', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -48,8 +55,11 @@ describe('OnboardingQuestionaire Component', () => {
rest.post(UPDATE_ORG_PREFERENCE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
),
rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
),
);
});

View File

@@ -11,7 +11,6 @@ import { AxiosError } from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
@@ -71,9 +70,6 @@ function OnboardingQuestionaire(): JSX.Element {
const [optimiseSignozDetails, setOptimiseSignozDetails] =
useState<OptimiseSignozDetails>(INITIAL_OPTIMISE_SIGNOZ_DETAILS);
const [teamMembers, setTeamMembers] = useState<
InviteTeamMembersProps[] | null
>(null);
const [updatingOrgOnboardingStatus, setUpdatingOrgOnboardingStatus] =
useState<boolean>(false);
@@ -232,8 +228,6 @@ function OnboardingQuestionaire(): JSX.Element {
{currentStep === 4 && (
<InviteTeamMembers
isLoading={updatingOrgOnboardingStatus}
teamMembers={teamMembers}
setTeamMembers={setTeamMembers}
onNext={handleOnboardingComplete}
/>
)}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
import { ArrowRight, Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
import {
Button,
Flex,
@@ -10,6 +10,8 @@ import {
Space,
Steps,
} from 'antd';
import { Button as SignozButton } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
@@ -27,7 +29,7 @@ import { isModifierKeyPressed } from 'utils/app';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import OnboardingIngestionDetails from '../IngestionDetails/IngestionDetails';
import InviteTeamMembers from '../InviteTeamMembers/InviteTeamMembers';
import InviteMembers from 'components/InviteMembers/InviteMembers';
import onboardingConfigWithLinks from '../onboarding-configs/onboarding-config-with-links';
import '../OnboardingV2.styles.scss';
@@ -119,6 +121,10 @@ const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
GET_HELP_BUTTON_CLICKED: 'Get help clicked',
GET_EXPERT_ASSISTANCE_BUTTON_CLICKED: 'Get expert assistance clicked',
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Invite team member clicked',
INVITE_TEAM_MEMBER_SEND_CLICKED: 'Send invites clicked',
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
CLOSE_ONBOARDING_CLICKED: 'Close onboarding clicked',
DATA_SOURCE_REQUESTED: 'Datasource requested',
DATA_SOURCE_SEARCHED: 'Searched',
@@ -1147,12 +1153,54 @@ function OnboardingAddDataSource(): JSX.Element {
destroyOnClose
>
<div className="invite-team-member-modal-content">
<InviteTeamMembers
isLoading={false}
teamMembers={null}
setTeamMembers={(): void => {}}
onNext={(): void => setShowInviteTeamMembersModal(false)}
onClose={(): void => setShowInviteTeamMembersModal(false)}
<InviteMembers
onSuccess={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
{},
);
setShowInviteTeamMembersModal(false);
toast.success('Invites sent successfully', { position: 'top-center' });
}}
onPartialSuccess={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_PARTIAL_SUCCESS}`,
{},
);
}}
onAllFailed={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
{},
);
}}
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
<div className="invite-team-member-modal-footer">
<SignozButton
variant="solid"
color="secondary"
onClick={(): void => setShowInviteTeamMembersModal(false)}
>
Cancel
</SignozButton>
<SignozButton
variant="solid"
onClick={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SEND_CLICKED}`,
{},
);
void submit();
}}
disabled={!canSubmit}
loading={isSubmitting}
suffix={<ArrowRight size={14} />}
>
Send Invites
</SignozButton>
</div>
)}
/>
</div>
</Modal>

View File

@@ -1,116 +0,0 @@
.team-member-container {
display: flex;
align-items: center;
.invite-team-members-form {
padding: 16px 0px;
}
.team-member-email-input {
width: 80%;
background-color: var(--l1-background);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-input,
.ant-input-group-addon {
background-color: var(--l1-background) !important;
border-right: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
.team-member-role-select {
width: 20%;
.ant-select-selector {
border: 1px solid var(--l1-border);
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
.remove-team-member-button {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.invite-team-members-container {
display: flex;
flex-direction: column;
gap: 8px;
.invite-team-members-add-another-member-container {
margin: 16px 0px;
display: flex;
justify-content: flex-end;
}
.next-prev-container {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.error-message-container,
.success-message-container,
.partially-sent-invites-container {
border-radius: 4px;
width: 100%;
display: flex;
align-items: center;
.error-message,
.success-message {
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
}
}
.invite-users-error-message-container,
.invite-users-success-message-container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.success-message {
color: var(--bg-success-500, #00b37e);
}
}
.partially-sent-invites-container {
margin-top: 16px;
padding: 8px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
box-sizing: border-box;
.partially-sent-invites-message {
color: var(--bg-warning-500, #fbbd23);
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
}
}
}

View File

@@ -1,298 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
import {
ArrowRight,
CircleCheck,
Plus,
TriangleAlert,
X,
} from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import './InviteTeamMembers.styles.scss';
interface TeamMember {
email: string;
role: string;
name: string;
frontendBaseUrl: string;
id: string;
}
interface InviteTeamMembersProps {
isLoading: boolean;
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
onClose: () => void;
}
const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
BASE: 'Onboarding V3',
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Send invites clicked',
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
};
function InviteTeamMembers({
isLoading,
teamMembers,
setTeamMembers,
onNext,
onClose,
}: InviteTeamMembersProps): JSX.Element {
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
TeamMember[] | null
>(teamMembers);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const { notifications } = useNotifications();
const defaultTeamMember: TeamMember = {
email: '',
role: 'EDITOR',
name: '',
frontendBaseUrl: getBaseUrl(),
id: '',
};
useEffect(() => {
if (isEmpty(teamMembers)) {
const teamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite([teamMember]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamMembers]);
const handleAddTeamMember = (): void => {
const newTeamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
};
const handleRemoveTeamMember = (id: string): void => {
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
};
// Validation function to check all users
const validateAllUsers = (): boolean => {
let isValid = true;
const updatedValidity: Record<string, boolean> = {};
teamMembersToInvite?.forEach((member) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
if (!emailValid || !member.email) {
isValid = false;
setHasInvalidEmails(true);
}
updatedValidity[member.id!] = emailValid;
});
setEmailValidity(updatedValidity);
return isValid;
};
const handleInviteUsersSuccess = (): void => {
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
{
teamMembers: teamMembersToInvite,
},
);
setTimeout(() => {
onNext();
}, 1000);
};
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
inviteUsers,
{
onSuccess: (): void => {
handleInviteUsersSuccess();
notifications.success({
message: 'Invites sent successfully!',
});
},
onError: (error: APIError): void => {
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
{
teamMembers: teamMembersToInvite,
error,
},
);
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
},
},
);
const handleNext = (): void => {
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite || []);
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
{
teamMembers: teamMembersToInvite,
},
);
setHasInvalidEmails(false);
sendInvites({
invites: teamMembersToInvite || [],
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedValidateEmail = useCallback(
debounce((email: string, memberId: string) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
}, 500),
[],
);
const handleEmailChange = (
e: React.ChangeEvent<HTMLInputElement>,
member: TeamMember,
): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id!);
}
};
const handleRoleChange = (role: string, member: TeamMember): void => {
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
memberToUpdate.role = role;
setTeamMembersToInvite(updatedMembers);
}
};
return (
<div className="invite-team-members-container">
<div className="invite-team-members-form">
<div className="form-group">
<div className="invite-team-members-container">
{teamMembersToInvite?.map((member) => (
<div className="team-member-container" key={member.id}>
<Input
placeholder="your-teammate@org.com"
value={member.email}
type="email"
required
autoFocus
autoComplete="off"
className="team-member-email-input"
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
handleEmailChange(e, member)
}
addonAfter={
emailValidity[member.id!] === undefined ? null : emailValidity[
member.id!
] ? (
<CircleCheck size={14} color={Color.BG_FOREST_500} />
) : (
<TriangleAlert size={14} color={Color.BG_SIENNA_500} />
)
}
/>
<Select
defaultValue={member.role}
onChange={(value): void => handleRoleChange(value, member)}
className="team-member-role-select"
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
{teamMembersToInvite?.length > 1 && (
<Button
type="default"
className="remove-team-member-button periscope-btn"
icon={<X size={14} />}
onClick={(): void => handleRemoveTeamMember(member.id)}
/>
)}
</div>
))}
</div>
<div className="invite-team-members-add-another-member-container">
<Button
type="primary"
className="add-another-member-button periscope-btn"
icon={<Plus size={14} />}
onClick={handleAddTeamMember}
>
Member
</Button>
</div>
</div>
{hasInvalidEmails && (
<div className="error-message-container">
<Typography.Text className="error-message" color="danger">
<TriangleAlert size={14} /> Please enter valid emails for all team
members
</Typography.Text>
</div>
)}
</div>
<div className="next-prev-container">
<Button
type="default"
className="next-button periscope-btn"
onClick={onClose}
>
<X size={14} />
Cancel
</Button>
<Button
type="primary"
className="next-button periscope-btn primary"
onClick={handleNext}
loading={isSendingInvites || isLoading}
>
Send Invites
<ArrowRight size={14} />
</Button>
</div>
</div>
);
}
export default InviteTeamMembers;

View File

@@ -1220,6 +1220,14 @@
.request-data-source-modal-input {
margin-top: 8px;
}
.invite-team-member-modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
border-top: 1px solid var(--l1-border);
padding-top: var(--spacing-6);
}
}
.request-data-source-modal {

View File

@@ -79,7 +79,7 @@
margin-bottom: 12px;
}
input,
input:not(.ant-select-selection-search-input),
textarea {
height: 32px;
background: var(--l2-background) !important;

View File

@@ -111,31 +111,9 @@
&__select {
width: 100%;
&.ant-select {
.ant-select-selector {
height: 32px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
border-radius: 2px;
color: var(--l2-foreground) !important;
.ant-select-selection-item {
color: var(--l2-foreground) !important;
}
}
&:hover .ant-select-selector {
border-color: var(--l2-border) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--primary) !important;
box-shadow: none !important;
}
.ant-select-arrow {
color: var(--l2-foreground);
}
.ant-select-selection-search {
inset-inline-start: var(--padding-2) !important;
inset-inline-end: var(--padding-2) !important;
}
}
@@ -185,7 +163,7 @@
&--role {
flex: 1;
min-width: 120px;
min-width: 180px;
}
}
@@ -272,7 +250,7 @@
}
// todo: https://github.com/SigNoz/components/issues/116
input {
input:not(.ant-select-selection-search-input) {
height: 32px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;

View File

@@ -11,23 +11,20 @@ import {
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import { Collapse, Form, Select, Tooltip } from 'antd';
import { Collapse, Form, Tooltip } from 'antd';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './RoleMappingSection.styles.scss';
const ROLE_OPTIONS = [
{ value: 'VIEWER', label: 'VIEWER' },
{ value: 'EDITOR', label: 'EDITOR' },
{ value: 'ADMIN', label: 'ADMIN' },
];
interface RoleMappingSectionProps {
fieldNamePrefix: string[];
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
}
const SIGNOZ_VIEWER_ROLE = 'signoz-viewer';
function RoleMappingSection({
fieldNamePrefix,
isExpanded,
@@ -38,6 +35,7 @@ function RoleMappingSection({
[...fieldNamePrefix, 'useRoleAttribute'],
form,
);
const { roles, isLoading, isError, error, refetch } = useRoles();
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -108,19 +106,26 @@ function RoleMappingSection({
<div className="role-mapping-section__field-group">
<label className="role-mapping-section__label" htmlFor="default-role">
Default Role
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "signoz-viewer"'>
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'defaultRole']}
className="role-mapping-section__form-item"
initialValue="VIEWER"
initialValue={SIGNOZ_VIEWER_ROLE}
>
<Select
<RolesSelect
id="default-role"
options={ROLE_OPTIONS}
valueField="name"
roles={roles}
loading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
className="role-mapping-section__select"
allowClear={false}
getPopupContainer={(): HTMLElement => document.body}
/>
</Form.Item>
</div>
@@ -140,7 +145,7 @@ function RoleMappingSection({
Use Role Attribute Directly
</Checkbox>
</Form.Item>
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role name (e.g. signoz-viewer, signoz-editor, signoz-admin, or a custom role).">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
@@ -174,11 +179,17 @@ function RoleMappingSection({
name={[field.name, 'role']}
className="role-mapping-section__field role-mapping-section__field--role"
rules={[{ required: true, message: 'Role is required' }]}
initialValue="VIEWER"
initialValue={SIGNOZ_VIEWER_ROLE}
>
<Select
options={ROLE_OPTIONS}
className="role-mapping-section__select"
<RolesSelect
valueField="name"
roles={roles}
loading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
allowClear={false}
getPopupContainer={(): HTMLElement => document.body}
/>
</Form.Item>
@@ -197,7 +208,9 @@ function RoleMappingSection({
<Button
variant="outlined"
color="secondary"
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
onClick={(): void =>
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
}
prefix={<Plus size={14} />}
>
Add Group Mapping

View File

@@ -9,6 +9,7 @@ import {
mockUpdateSuccessResponse,
} from './mocks';
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
// which mocks @signozhq/ui/switch for the same reason.

View File

@@ -0,0 +1,316 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
allRoles,
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithDirectRoleAttribute,
mockDomainWithRoleMapping,
mockSamlAuthDomain,
mockUpdateSuccessResponse,
} from './mocks';
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
// prevent form.validateFields() from resolving inside act(). Replacing with a
// simple native button avoids the issue.
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
// These are heavy real-timer integration tests (antd Select dropdown render +
// form.validateFields() + a react-query mutation, all driven through userEvent).
// Under a CPU-saturated parallel `jest` run the wall-clock roughly triples, which
// pushes the longest tests past the 5000ms default and makes them flaky. Give the
// whole file a wider budget (matches LogsPanelComponent.test.tsx).
jest.setTimeout(20000);
const ROLES_ENDPOINT = '*/api/v1/roles';
type User = ReturnType<typeof userEvent.setup>;
// antd renders pointer-events:none on parts of its Select, so disable the
// userEvent pointer-events guard (mirrors CreateEdit.test.tsx).
const setupUser = (): User => userEvent.setup({ pointerEventsCheck: 0 });
function getRole(name: string): (typeof managedRoles)[number] {
const role = managedRoles.find((r) => r.name === name);
if (!role) {
throw new Error(`missing mock role: ${name}`);
}
return role;
}
const viewerRole = getRole('signoz-viewer');
const editorRole = getRole('signoz-editor');
function mockRoles(
response: Record<string, unknown> = listRolesSuccessResponse,
status = 200,
): { count: () => number } {
let requested = 0;
server.use(
rest.get(ROLES_ENDPOINT, (_req, res, ctx) => {
requested += 1;
return res(ctx.status(status), ctx.json(response));
}),
);
return { count: (): number => requested };
}
function captureUpdatePayload(): { get: () => any } {
let payload: unknown = null;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
payload = await req.json();
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
return { get: (): any => payload };
}
const expandRoleMapping = (user: User): Promise<void> =>
user.click(screen.getByText(/role mapping \(advanced\)/i));
const openDefaultRoleSelect = (user: User): Promise<void> =>
user.click(screen.getByLabelText(/default role/i));
const saveChanges = (user: User): Promise<void> =>
user.click(screen.getByRole('button', { name: /save changes/i }));
describe('CreateEdit — role mapping uses API roles', () => {
afterEach(() => {
server.resetHandlers();
});
it('fetches the roles list from the API when the form mounts', async () => {
const roles = mockRoles();
render(
<CreateEdit
isCreate={false}
record={mockDomainWithDirectRoleAttribute}
onClose={jest.fn()}
/>,
);
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
});
it('renders the default-role options from the API (managed + custom), not the old hardcoded VIEWER/EDITOR/ADMIN', async () => {
const user = setupUser();
mockRoles();
// mockSamlAuthDomain has no stored defaultRole, so nothing stale (e.g.
// "VIEWER") is rendered as a selected tag to pollute the title lookups.
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// Open the Select and wait for the async roles fetch to populate it.
await openDefaultRoleSelect(user);
await screen.findByTitle(allRoles[0].name);
// Every role returned by the API is offered as an option, including the
// custom (non-managed) roles — the whole point of the refactor. Use
// getAllByTitle: the preselected default role also renders its name on
// the selection item, so a role may legitimately appear more than once.
allRoles.forEach((role) => {
expect(screen.getAllByTitle(role.name).length).toBeGreaterThan(0);
});
// The old hardcoded uppercase role values must NOT appear as options.
expect(screen.queryByTitle('VIEWER')).not.toBeInTheDocument();
expect(screen.queryByTitle('EDITOR')).not.toBeInTheDocument();
expect(screen.queryByTitle('ADMIN')).not.toBeInTheDocument();
});
it('submits the selected role name (not the role id) as defaultRole', async () => {
const user = setupUser();
mockRoles();
const payload = captureUpdatePayload();
render(
<CreateEdit
isCreate={false}
record={mockDomainWithDirectRoleAttribute}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
await openDefaultRoleSelect(user);
await user.click(await screen.findByTitle(editorRole.name));
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
// SSO role mapping matches roles by name, so the payload carries the
// role *name*, not the opaque id.
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
});
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
const user = setupUser();
const roles = mockRoles();
const payload = captureUpdatePayload();
// mockSamlAuthDomain has no roleMapping, so the defaultRole field falls
// back to the Form.Item initialValue (viewerRole.name). That initialValue
// is only applied when the field mounts, so the roles fetch MUST resolve
// before the panel is expanded — otherwise viewerRole is still undefined.
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
// Flush the react-query commit so `useRoles` exposes the loaded roles
// before the collapse panel (and thus the default-role field) mounts.
await screen.findByText(/edit saml authentication/i);
await expandRoleMapping(user);
await screen.findByText(/default role/i);
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
});
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
const user = setupUser();
// signoz-viewer is a managed role that always exists server-side, so even
// a degenerate/empty roles response must not strip the hardcoded default.
mockRoles({ status: 'success', data: [] });
const payload = captureUpdatePayload();
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
// Section still renders without crashing even though the fetch was empty.
await expandRoleMapping(user);
await expect(screen.findByText(/default role/i)).resolves.toBeInTheDocument();
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
});
it('loads a stored role mapping by role name and round-trips it on save', async () => {
const user = setupUser();
mockRoles();
const payload = captureUpdatePayload();
// mockDomainWithRoleMapping stores defaultRole "signoz-editor" plus three
// group mappings, all keyed by role *name*. Editing must surface each
// stored value as the matching option and submit it unchanged — the
// backward-compatible read path for already-saved SSO domains.
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// The stored default role renders as a real selection, not a raw token.
await waitFor(() =>
expect(screen.getAllByTitle(editorRole.name).length).toBeGreaterThan(0),
);
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
});
});
it('shows an error state in the default-role select when the roles request fails', async () => {
const user = setupUser();
mockRoles(
{ error: { code: 'internal_error', message: 'boom', url: '' } },
500,
);
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// Open the select and confirm the error UI (with retry) is surfaced
// instead of crashing the form. The error message comes straight from
// the failed request; the Retry affordance is always present.
await openDefaultRoleSelect(user);
await expect(screen.findByTitle('Retry')).resolves.toBeInTheDocument();
expect(screen.getByText('boom')).toBeInTheDocument();
});
});

View File

@@ -186,9 +186,9 @@ describe('CreateEdit — payload sanitization', () => {
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
});
});
});

View File

@@ -75,12 +75,12 @@ export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
},
},
},
@@ -103,7 +103,7 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
},
},

View File

@@ -1,7 +1,6 @@
.rolesListingTable {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.scrollContainer {

View File

@@ -40,6 +40,7 @@
.rolesSettingsContent {
padding: 0 16px;
padding-bottom: 16px;
}
.rolesSettingsToolbar {

View File

@@ -32,8 +32,6 @@ interface ConfigPaneProps {
tableColumns: TableColumnOption[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}
/**
@@ -51,7 +49,6 @@ function ConfigPane({
legendSeries,
tableColumns,
stepInterval,
metricUnit,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
@@ -111,7 +108,6 @@ function ConfigPane({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
))}
</div>

View File

@@ -34,7 +34,6 @@ function SectionSlot({
onChangePanelKind,
queryType,
stepInterval,
metricUnit,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
@@ -75,7 +74,6 @@ function SectionSlot({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</SettingsSection>
);

View File

@@ -19,6 +19,4 @@ export interface SectionEditorContext {
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}

View File

@@ -46,10 +46,11 @@ function DisconnectValuesField({
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
// `fillOnlyBelow` is authoritative; fall back to a stored duration for legacy panels.
const isThreshold = value?.fillOnlyBelow ?? !!duration;
// Remember the last committed threshold so Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState<string | undefined>(duration);
const isThreshold = !!duration;
// Remember the last threshold so toggling Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState(
duration ?? defaultDuration(stepInterval),
);
useEffect(() => {
if (duration) {
@@ -58,17 +59,11 @@ function DisconnectValuesField({
}, [duration]);
const handleMode = (mode: DisconnectValuesMode): void => {
if (mode === DisconnectValuesMode.THRESHOLD) {
onChange({
...value,
fillOnlyBelow: true,
// Seed from the live stepInterval (async — undefined until results load), not mount.
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
});
return;
}
// Never spans every gap; drop the duration so the renderer reads a clean "span all".
onChange({ ...value, fillOnlyBelow: false, fillLessThan: undefined });
onChange(
mode === DisconnectValuesMode.THRESHOLD
? { ...value, fillLessThan: lastDuration }
: undefined,
);
};
return (
@@ -84,16 +79,14 @@ function DisconnectValuesField({
onChange={handleMode}
/>
</div>
{isThreshold && duration && (
{isThreshold && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={duration}
value={lastDuration}
minValue={stepInterval}
onChange={(next): void =>
onChange({ ...value, fillOnlyBelow: true, fillLessThan: next })
}
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
/>
</div>
)}

View File

@@ -14,28 +14,6 @@ interface DisconnectValuesThresholdInputProps {
onChange: (duration: string) => void;
}
/**
* Inline error for a raw duration, or `null` when valid and in range. The parse is
* guarded: `isValidTimeSpan` passes some strings `intervalToSeconds` throws on (e.g. "5x").
*/
function validationError(raw: string, minValue?: number): string | null {
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
return 'Enter a valid duration (e.g. 30s, 1m, 1h)';
}
if (minValue !== undefined && seconds < minValue) {
return `Threshold should be > ${rangeUtil.secondsToHms(minValue)}`;
}
return null;
}
/**
* Duration input for the span-gaps threshold: shows/accepts and reports a human
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
@@ -58,21 +36,24 @@ function DisconnectValuesThresholdInput({
setError(null);
}, [value]);
// Validate live so an invalid entry surfaces immediately, not only on blur.
const handleText = (raw: string): void => {
setText(raw);
setError(raw ? validationError(raw, minValue) : null);
};
const commit = (raw: string): void => {
// Skip no-op commits: blur fires when clicking the Never toggle, and re-emitting
// the unchanged value there would race the toggle and snap back to Threshold.
if (!raw || raw === value) {
if (!raw) {
return;
}
const message = validationError(raw, minValue);
if (message) {
setError(message);
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
return;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
@@ -88,9 +69,12 @@ function DisconnectValuesThresholdInput({
status={error ? 'error' : undefined}
prefix={<span className={styles.thresholdPrefix}>&gt;</span>}
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
handleText(e.target.value)
}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setText(e.target.value);
if (error) {
setError(null);
}
}}
onBlur={(e): void => commit(e.currentTarget.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {

View File

@@ -1,34 +1,9 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
DashboardtypesLineStyleDTO,
type DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
import ChartAppearanceSection from '../ChartAppearanceSection';
/** Stateful wrapper that feeds onChange back as the spec, mirroring the real editor. */
function StatefulSpanGaps({
initial,
stepInterval,
}: {
initial?: DashboardtypesTimeSeriesChartAppearanceDTO;
stepInterval?: number;
}): JSX.Element {
const [value, setValue] = useState<
DashboardtypesTimeSeriesChartAppearanceDTO | undefined
>(initial);
return (
<ChartAppearanceSection
value={value}
controls={{ spanGaps: true }}
stepInterval={stepInterval}
onChange={setValue}
/>
);
}
// Open the antd Select by clicking its selector, then pick the option by label. The
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
// only used for the line-interpolation ConfigSelect.
@@ -164,7 +139,7 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
spanGaps: { fillLessThan: '1m' },
});
});
@@ -187,7 +162,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
spanGaps: { fillLessThan: '5m' },
});
});
@@ -208,7 +183,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '300' },
spanGaps: { fillLessThan: '300' },
});
});
@@ -225,24 +200,7 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Never'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
});
});
it('selects Never when fillOnlyBelow is false even if a duration lingers', () => {
render(
<ChartAppearanceSection
value={{ spanGaps: { fillOnlyBelow: false, fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
// The flag is authoritative: a stale fillLessThan must not show Threshold.
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
it('shows an error and does not commit an invalid duration', async () => {
@@ -286,117 +244,4 @@ describe('ChartAppearanceSection', () => {
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('seeds the threshold from the step interval when switching to Threshold', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('seeds from the step interval even when it arrives after mount', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
// The step interval is undefined until the query response carries step metadata,
// so the panel first renders without it and receives it on a later render.
const { rerender } = render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
rerender(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
// Regression: a value seeded at mount would still be the 1m fallback.
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('shows a validation error while typing, before blur', async () => {
const user = userEvent.setup();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.clear(input);
await user.type(input, 'abc');
// No blur / Enter — the error must already be visible.
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
});
it('does not re-commit the threshold when blurred without a change', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.click(input);
await user.tab();
expect(onChange).not.toHaveBeenCalled();
});
it('fully switches from Threshold to Never (the input disappears)', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '1m' } }} />);
expect(
screen.getByTestId('panel-editor-v2-span-gaps-value'),
).toBeInTheDocument();
// Focus the input first so clicking Never also fires its blur (the toggle race).
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
await user.click(screen.getByText('Never'));
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('remembers the last threshold when toggling Never → Threshold', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
await user.click(screen.getByText('Never'));
await user.click(screen.getByText('Threshold'));
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
'5m',
);
});
});

View File

@@ -14,7 +14,7 @@ import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
Pick<SectionEditorContext, 'tableColumns'>;
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {
@@ -39,7 +39,6 @@ function FormattingSection({
controls,
onChange,
tableColumns = [],
metricUnit,
}: FormattingSectionProps): JSX.Element {
return (
<>
@@ -51,7 +50,6 @@ function FormattingSection({
data-testid="panel-editor-v2-unit"
source={YAxisSource.DASHBOARDS}
value={value?.unit}
initialValue={metricUnit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>

View File

@@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
async function pickDecimal(label: string): Promise<void> {
@@ -73,31 +71,4 @@ describe('FormattingSection', () => {
decimalPrecision: '2',
});
});
it('warns when the selected unit mismatches the metric unit', () => {
// metric sent in seconds, but bytes is selected.
render(
<FormattingSection
value={{ unit: 'By' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.getByLabelText('warning')).toBeInTheDocument();
});
it('shows no warning when the selected unit matches the metric unit', () => {
render(
<FormattingSection
value={{ unit: 's' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
@@ -82,15 +82,6 @@ function ThresholdsSection({
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
// The saved threshold captured on edit entry, restored if the edit is discarded
// (edits stream into the spec live, so Discard can't just drop a local draft).
const editSnapshot = useRef<AnyThreshold | null>(null);
const updateAt =
(index: number) =>
(next: AnyThreshold): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
};
const addThreshold = (): void => {
const nextIndex = thresholds.length;
@@ -99,11 +90,6 @@ function ThresholdsSection({
setUnsavedIndex(nextIndex);
};
const beginEdit = (index: number): void => {
editSnapshot.current = thresholds[index] ?? null;
setEditingIndex(index);
};
const saveAt =
(index: number) =>
(next: AnyThreshold): void => {
@@ -119,15 +105,11 @@ function ThresholdsSection({
};
const discardAt = (index: number) => (): void => {
// A never-saved row is removed; otherwise revert the live edits to the snapshot.
// Discarding a row that was never saved removes it; otherwise just exit edit.
if (index === unsavedIndex) {
removeAt(index);
return;
}
const original = editSnapshot.current;
if (original) {
onChange(thresholds.map((t, i) => (i === index ? original : t)));
}
setEditingIndex(null);
};
@@ -138,9 +120,8 @@ function ThresholdsSection({
index,
yAxisUnit,
isEditing: editingIndex === index,
onEdit: (): void => beginEdit(index),
onEdit: (): void => setEditingIndex(index),
onSave: saveAt(index),
onLiveChange: updateAt(index),
onDiscard: discardAt(index),
onRemove: (): void => removeAt(index),
};

View File

@@ -36,16 +36,9 @@ const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
},
];
// Stateful harness for flows that depend on the value updating (add/discard/live).
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: DashboardtypesComparisonThresholdDTO[];
}): JSX.Element {
const [value, setValue] =
useState<DashboardtypesComparisonThresholdDTO[]>(initial);
// Stateful harness for flows that depend on the value updating (add/discard).
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
return (
<ComparisonThresholdsSection
value={value}
@@ -149,44 +142,24 @@ describe('ComparisonThresholdsSection', () => {
expect(valueInput).toHaveValue(5);
});
it('reflects edits live (before Save) so the preview can react', async () => {
it('does not commit edits when Discard is clicked', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
// No Save click — the latest edit is already pushed up for the preview.
expect(onChange).toHaveBeenLastCalledWith([
{
value: 90,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
]);
});
it('reverts the live edits to the saved value on Discard', async () => {
const user = userEvent.setup();
render(<Harness initial={THRESHOLDS} />);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', async () => {

View File

@@ -10,16 +10,10 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
];
// Stateful harness for flows that depend on the value updating (add/discard/live);
// Stateful harness for flows that depend on the value updating (add/discard);
// omits `controls` to exercise the default `label` variant.
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: AnyThreshold[];
}): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>(initial);
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
);
@@ -76,34 +70,19 @@ describe('ThresholdsSection', () => {
]);
});
it('reflects edits live (before Save) so the preview can react', () => {
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
// No Save click — the edit is already pushed up for the preview to render.
expect(onChange).toHaveBeenLastCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]);
});
it('reverts the live edits to the saved value on Discard', () => {
render(<Harness initial={THRESHOLDS} />);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-discard-0'));
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', () => {

View File

@@ -27,7 +27,6 @@ interface ComparisonThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
onLiveChange: (next: DashboardtypesComparisonThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -43,15 +42,10 @@ function ComparisonThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: ComparisonThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (

View File

@@ -20,7 +20,6 @@ interface LabelThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onLiveChange: (next: DashboardtypesThresholdWithLabelDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -33,15 +32,10 @@ function LabelThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: LabelThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Persist an empty-string label when none was entered — the spec requires a string.
const handleSave = useCallback((): void => {

View File

@@ -28,7 +28,6 @@ interface TableThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesTableThresholdDTO) => void;
onLiveChange: (next: DashboardtypesTableThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -46,15 +45,10 @@ function TableThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: TableThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Stored columnName is the query key; resolve its label + configured unit.
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;

View File

@@ -9,14 +9,12 @@ interface ThresholdDraft<T> {
/**
* Local draft for a threshold row, shared by every variant. Snapshots the saved
* threshold on each entry into edit mode and exposes the numeric `value` setter all
* variants use. `onLiveChange` mirrors the draft into the spec as the user edits, so the
* panel preview updates live (without Save); the section reverts it on Discard.
* threshold on each entry into edit mode (so Discard simply drops the draft and the
* next edit starts clean) and exposes the numeric `value` setter all variants use.
*/
export function useThresholdDraft<T extends { value: number }>(
threshold: T,
isEditing: boolean,
onLiveChange?: (draft: T) => void,
): ThresholdDraft<T> {
const [draft, setDraft] = useState<T>(threshold);
@@ -27,13 +25,6 @@ export function useThresholdDraft<T extends { value: number }>(
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
useEffect(() => {
if (isEditing) {
onLiveChange?.(draft);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- propagate on draft change only
}, [draft]);
const setValue = (raw: string): void => {
const next = Number(raw);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));

View File

@@ -1,8 +1,6 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
TelemetrytypesSignalDTO,
type DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -97,141 +95,4 @@ describe('getSwitchedPluginSpec', () => {
expect(result.legend?.position).toBe('bottom');
});
describe('thresholds', () => {
it('does not carry thresholds when the new kind has no thresholds section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toBeUndefined();
});
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/BarChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/NumberPanel',
TelemetrytypesSignalDTO.logs,
);
// The label is dropped; operator/format are seeded so the threshold can match.
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TablePanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops the table-only columnName when remapping into the label variant', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
});
it('defaults the variant to label when the thresholds section omits controls', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: {} }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', label: 'warn' },
]);
});
});
});

View File

@@ -1,18 +1,13 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
type TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
SectionKind,
type ThresholdVariant,
type PanelFormattingSlice,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
@@ -29,73 +24,13 @@ import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
thresholds?: AnyThreshold[];
}
/** Every field any threshold variant can hold; switching reads across shapes to remap. */
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** The threshold variant a kind edits, or `undefined` when it has no Thresholds section. */
function getThresholdVariant(
sections: SectionConfig[],
): ThresholdVariant | undefined {
const section = sections.find(
(s): s is Extract<SectionConfig, { kind: SectionKind.Thresholds }> =>
s.kind === SectionKind.Thresholds,
);
return section ? (section.controls.variant ?? 'label') : undefined;
}
/**
* Remaps a threshold to the target kind's variant: keeps the shared core (color, value,
* unit) plus any cross-variant fields, and seeds the rest with the variant's defaults so
* the carried threshold stays functional (a comparison/table threshold needs an operator
* to match, a table threshold a column).
*/
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === 'comparison') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === 'table') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the cross-kind config worth keeping — unit + decimal precision, and thresholds when the
* new kind supports them (remapped to its variant). Switching into a List seeds the
* current signal's default columns so the columns control isn't empty.
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
* List seeds the current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
@@ -131,19 +66,5 @@ export function getSwitchedPluginSpec(
}
}
const thresholdVariant = getThresholdVariant(sections);
if (thresholdVariant) {
const oldThresholds = (
oldSpec.plugin.spec as {
thresholds?: AnyThreshold[] | null;
}
).thresholds;
if (oldThresholds && oldThresholds.length > 0) {
result.thresholds = oldThresholds.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, thresholdVariant),
);
}
}
return result;
}

View File

@@ -1,103 +0,0 @@
import { renderHook } from '@testing-library/react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
describe('useMetricYAxisUnit', () => {
beforeEach(() => jest.clearAllMocks());
it('seeds the unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the metric has no unit', () => {
mockMetricUnit(undefined);
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
const { rerender } = renderHook(
(props: { unit: string | undefined }) =>
useMetricYAxisUnit({
isNewPanel: true,
unit: props.unit,
onSelectUnit,
}),
{ initialProps: { unit: undefined as string | undefined } },
);
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
// The metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ unit: 'bytes' });
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useMetricYAxisUnit({
isNewPanel: false,
unit: undefined,
onSelectUnit: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -1,36 +0,0 @@
import { useEffect } from 'react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
interface UseMetricYAxisUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
isNewPanel: boolean;
unit: string | undefined;
onSelectUnit: (unit: string) => void;
}
interface UseMetricYAxisUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
* from it (V1 parity); returns the unit for the selector's mismatch warning.
*/
export function useMetricYAxisUnit({
isNewPanel,
unit,
onSelectUnit,
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
useEffect(() => {
if (isNewPanel && metricUnit && metricUnit !== unit) {
onSelectUnit(metricUnit);
}
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewPanel, metricUnit]);
return { metricUnit, isLoading };
}

View File

@@ -8,8 +8,6 @@ import {
import { toast } from '@signozhq/ui/sonner';
import {
type DashboardtypesPanelDTO,
type DashboardtypesPanelFormattingDTO,
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -32,7 +30,6 @@ import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
@@ -126,33 +123,6 @@ function PanelEditorContainer({
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
}
).formatting?.unit;
const seedFormattingUnit = useCallback(
(unit: string): void => {
const pluginSpec = spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
};
setSpec({
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
},
} as DashboardtypesPanelSpecDTO);
},
[spec, setSpec],
);
const { metricUnit } = useMetricYAxisUnit({
isNewPanel: isNew,
unit: formattingUnit,
onSelectUnit: seedFormattingUnit,
});
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
@@ -280,7 +250,6 @@ function PanelEditorContainer({
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -1,130 +0,0 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { preparePieData } from '../prepareData';
function tableWith(
columns: PanelTable['columns'],
rows: PanelTable['rows'],
overrides: Partial<PanelTable> = {},
): PanelTable {
return { queryName: 'A', legend: '', columns, rows, ...overrides };
}
const args = (tables: PanelTable[]): Parameters<typeof preparePieData>[0] => ({
tables,
isDarkMode: true,
});
describe('preparePieData', () => {
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 23399927, col2: 588691297 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['col1', 23399927],
['col2', 588691297],
]);
});
it('keeps one slice per group row for a single value column', () => {
const table = tableWith(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['adservice', 100],
['cartservice', 200],
]);
});
it('prefixes the group when multiple value columns are grouped', () => {
const table = tableWith(
[
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'prod · col1',
'prod · col2',
]);
});
it('falls back to legend/query name when a single value column has no group', () => {
const table = tableWith(
[{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' }],
[{ data: { A: 42 } }],
{ legend: 'requests' },
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['requests', 42],
]);
});
it('honours customColors over the generated palette', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 10, col2: 20 } }],
);
const slices = preparePieData({
tables: [table],
isDarkMode: true,
customColors: { col1: '#ff0000' },
});
expect(slices[0].color).toBe('#ff0000');
expect(slices[1].color).not.toBe('#ff0000');
});
it('drops non-positive and non-numeric values', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
],
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
});
it('returns no slices for empty tables', () => {
expect(preparePieData(args([]))).toStrictEqual([]);
});
});

View File

@@ -11,7 +11,11 @@ export interface PreparePieDataArgs {
isDarkMode: boolean;
}
/** One pie slice per (row × value column); column name labels slices when a query has several value columns. */
/**
* Turns the scalar tables of a V5 response into pie slices (one per group row):
* value column → value, group column(s) → label. Colours honour `customColors`
* then fall back to the deterministic palette; non-positive/non-numeric dropped.
*/
export function preparePieData({
tables,
customColors,
@@ -23,35 +27,26 @@ export function preparePieData({
const slices: PieSlice[] = [];
tables.forEach((table) => {
const valueColumns = table.columns.filter((column) => column.isValueColumn);
if (valueColumns.length === 0) {
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
const hasMultipleValueColumns = valueColumns.length > 1;
table.rows.forEach((row) => {
const groupLabel = labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ');
valueColumns.forEach((column) => {
let label: string;
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
label = groupLabel || table.legend || table.queryName || '';
}
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({
label,
value: Number(row.data[column.id || column.name]),
color,
});
});
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ') ||
table.legend ||
table.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});

View File

@@ -108,9 +108,7 @@ function addSeries({
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance?.spanGaps)
: true;
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]

View File

@@ -1,35 +1,22 @@
import { resolveSpanGaps } from '../resolvers';
describe('resolveSpanGaps', () => {
it('parses a duration string into seconds when thresholding', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '5s' })).toBe(5);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '10m' })).toBe(
600,
);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '1h' })).toBe(
3600,
);
it('spans all gaps (true) when unset', () => {
expect(resolveSpanGaps(undefined)).toBe(true);
expect(resolveSpanGaps('')).toBe(true);
});
it('parses a duration string into seconds', () => {
expect(resolveSpanGaps('5s')).toBe(5);
expect(resolveSpanGaps('10m')).toBe(600);
expect(resolveSpanGaps('1h')).toBe(3600);
});
it('tolerates a bare seconds number (back-compat)', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '600' })).toBe(
600,
);
expect(resolveSpanGaps('600')).toBe(600);
});
it('falls back to true for unparseable input', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: 'abc' })).toBe(
true,
);
});
it('spans all gaps when fillOnlyBelow is explicitly false, ignoring any duration', () => {
expect(resolveSpanGaps({ fillOnlyBelow: false, fillLessThan: '5m' })).toBe(
true,
);
});
it('treats a duration with no fillOnlyBelow flag as a threshold (legacy panels)', () => {
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
expect(resolveSpanGaps('abc')).toBe(true);
});
});

View File

@@ -2,7 +2,6 @@ import { rangeUtil } from '@grafana/data';
import {
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
type DashboardtypesSpanGapsDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
@@ -40,14 +39,15 @@ export function resolveDecimalPrecision(
}
/**
* Resolves `spanGaps` to uPlot's value. `fillOnlyBelow: false` spans every gap regardless
* of `fillLessThan`; a duration with no flag still thresholds (panels predating the flag).
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
* bare seconds number for back-compat.
*/
export function resolveSpanGaps(
spanGaps: DashboardtypesSpanGapsDTO,
fillLessThan: string | undefined,
): boolean | number {
const fillLessThan = spanGaps.fillLessThan;
if (spanGaps.fillOnlyBelow === false || !fillLessThan) {
if (!fillLessThan) {
return true;
}
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)

View File

@@ -14,6 +14,9 @@
],
"additionalProperties": false,
"properties": {
"appId": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
@@ -26,8 +29,17 @@
],
"additionalProperties": false,
"properties": {
"apiHost": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"key": {
"type": "string"
},
"uiHost": {
"type": "string"
}
},
"type": "object"
@@ -38,8 +50,14 @@
],
"additionalProperties": false,
"properties": {
"appId": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"identitySecret": {
"type": "string"
}
},
"type": "object"
@@ -50,8 +68,14 @@
],
"additionalProperties": false,
"properties": {
"dsn": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"tunnel": {
"type": "string"
}
},
"type": "object"

View File

@@ -7,14 +7,22 @@ export interface WebSettings {
sentry: Sentry;
}
export interface Appcues {
appId?: string;
enabled: boolean;
}
export interface Posthog {
apiHost?: string;
enabled: boolean;
key?: string;
uiHost?: string;
}
export interface Pylon {
appId?: string;
enabled: boolean;
identitySecret?: string;
}
export interface Sentry {
dsn?: string;
enabled: boolean;
tunnel?: string;
}

View File

@@ -451,6 +451,23 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
ID: "QueryRangePreviewV5",
Tags: []string{"querier"},
Summary: "Query range preview",
Description: "Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.",
Request: new(qbtypes.QueryRangeRequest),
RequestQuery: new(qbtypes.QueryRangePreviewParams),
RequestContentType: "application/json",
Response: new(qbtypes.QueryRangePreviewResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/substitute_vars", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.ReplaceVariables), handler.OpenAPIDef{
ID: "ReplaceVariables",
Tags: []string{"querier"},

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-1,.cls-2,.cls-3{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_SQL_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="4.67 10.44 4.67 13.45 12 17.35 12 14.34 4.67 10.44"/><polygon class="cls-1" points="4.67 15.09 4.67 18.1 12 22 12 18.99 4.67 15.09"/><polygon class="cls-2" points="12 17.35 19.33 13.45 19.33 10.44 12 14.34 12 17.35"/><polygon class="cls-2" points="12 22 19.33 18.1 19.33 15.09 12 18.99 12 22"/><polygon class="cls-3" points="19.33 8.91 19.33 5.9 12 2 12 5.01 19.33 8.91"/><polygon class="cls-2" points="12 2 4.67 5.9 4.67 8.91 12 5.01 12 2"/><polygon class="cls-1" points="4.67 5.87 4.67 8.89 12 12.79 12 9.77 4.67 5.87"/><polygon class="cls-2" points="12 12.79 19.33 8.89 19.33 5.87 12 9.77 12 12.79"/></g></g></svg>

After

Width:  |  Height:  |  Size: 933 B

View File

@@ -0,0 +1,27 @@
{
"id": "cloudsql",
"title": "GCP Cloud SQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL Overview",
"description": "Overview of GCP Cloud SQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor GCP Cloud SQL with SigNoz
Collect key GCP Cloud SQL metrics and view them with an out of the box dashboard.

View File

@@ -481,6 +481,7 @@ func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// TODO: Rename AgentCheckIn to just CheckIn.
func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

View File

@@ -0,0 +1,88 @@
package clickhouseprometheus
import (
"context"
"sync"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
)
// statementRecorder collects the statements a PromQL evaluation would run.
// Safe for concurrent use: the engine may Select selectors concurrently.
type statementRecorder struct {
mu sync.Mutex
statements []prometheus.CapturedStatement
}
func (r *statementRecorder) record(query string, args []any) {
r.mu.Lock()
defer r.mu.Unlock()
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
}
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]prometheus.CapturedStatement, len(r.statements))
copy(out, r.statements)
return out
}
// captureClient builds the same SQL as the real client but records it and
// returns an empty result instead of executing.
type captureClient struct {
*client
recorder *statementRecorder
}
func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (storage.SeriesSet, error) {
// Raw-SQL passthrough ({job="rawsql", query="..."}): record the raw query.
if len(query.Matchers) == 2 {
var hasJob bool
var queryString string
for _, m := range query.Matchers {
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
hasJob = true
}
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
queryString = m.Value
}
}
if hasJob && queryString != "" {
c.recorder.record(queryString, nil)
return storage.EmptySeriesSet(), nil
}
}
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
// Build the executing path's queries, but only record them.
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil
}
// captureQueryable adapts the capturing read client to storage.Queryable.
type captureQueryable struct {
inner storage.SampleAndChunkQueryable
}
func (c captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
querier, err := c.inner.Querier(mint, maxt)
if err != nil {
return nil, err
}
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}

View File

@@ -204,8 +204,9 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
return fingerprints, nil
}
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")
// buildSamplesQuery renders the samples SQL (and args) that fetches data
// points for the series selected by subQuery.
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
argCount := len(args)
query := fmt.Sprintf(`
@@ -217,6 +218,13 @@ func (client *client) querySamples(ctx context.Context, start int64, end int64,
allArgs := append([]any{metricName}, args...)
allArgs = append(allArgs, start, end)
return query, allArgs
}
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")
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
if err != nil {

View File

@@ -5,8 +5,8 @@ import (
"sort"
"testing"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/stretchr/testify/require"
"github.com/DATA-DOG/go-sqlmock"

View File

@@ -64,3 +64,15 @@ func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}
// CapturingStorage implements prometheus.StatementCapturer. Uses a fresh
// recorder per call so concurrent dry-runs don't share state.
func (provider *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
recorder := &statementRecorder{}
capture := &captureClient{
client: &client{settings: provider.settings, telemetryStore: provider.telemetryStore},
recorder: recorder,
}
queryable := remote.NewSampleAndChunkQueryableClient(capture, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
return captureQueryable{inner: queryable}, recorder
}

View File

@@ -15,3 +15,23 @@ type Prometheus interface {
Storage() storage.Queryable
Parser() Parser
}
// CapturedStatement is one datastore statement a PromQL query would run,
// captured without executing.
type CapturedStatement struct {
Query string
Args []any
}
// StatementRecorder reads back the statements captured against a capturing
// Storage (see StatementCapturer).
type StatementRecorder interface {
Statements() []CapturedStatement
}
// StatementCapturer is an optional Prometheus-provider capability, discovered
// via type assertion: it returns a Storage that records each Select's statement
// without executing it, plus a recorder to read them back.
type StatementCapturer interface {
CapturingStorage() (storage.Queryable, StatementRecorder)
}

View File

@@ -73,6 +73,53 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, queryRangeResponse)
}
// QueryRangePreview is the dry-run counterpart of QueryRange: it validates and
// renders each query without executing it.
func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "querier",
instrumentationtypes.CodeFunctionName: "QueryRangePreview",
})
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
var queryRangeRequest qbtypes.QueryRangeRequest
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
// Validation is deferred to QueryRangePreview, which reports per-query
// errors instead of failing fast.
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
previewParams := qbtypes.QueryRangePreviewParams{Verbose: req.URL.Query().Get("verbose")}
previewOpts, err := previewParams.Validate()
if err != nil {
render.Error(rw, err)
return
}
preview, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, previewOpts)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, preview)
}
func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -36,6 +36,7 @@ type builderQuery[T any] struct {
}
var _ qbtypes.Query = (*builderQuery[any])(nil)
var _ qbtypes.StatementProvider = (*builderQuery[any])(nil)
type builderConfig struct {
logTraceIDWindowPaddingMS uint64
@@ -211,6 +212,11 @@ func (q *builderQuery[T]) isWindowList() bool {
return true
}
// Statement renders the SQL without executing it, for the preview path.
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
}
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
// can we do window based pagination?

View File

@@ -32,6 +32,7 @@ type chSQLQuery struct {
}
var _ qbtypes.Query = (*chSQLQuery)(nil)
var _ qbtypes.StatementProvider = (*chSQLQuery)(nil)
func newchSQLQuery(
logger *slog.Logger,
@@ -99,6 +100,15 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
return newQuery.String(), nil
}
// Statement renders the SQL without executing it, for the preview path.
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
if err != nil {
return nil, err
}
return &qbtypes.Statement{Query: rendered, Args: q.args}, nil
}
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),

View File

@@ -14,6 +14,8 @@ type Querier interface {
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
statsreporter.StatsCollector
// QueryRangePreview validates and renders the queries without executing them.
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
}
// BucketCache is the interface for bucket-based caching.
@@ -26,6 +28,8 @@ type BucketCache interface {
type Handler interface {
QueryRange(rw http.ResponseWriter, req *http.Request)
// QueryRangePreview is the dry-run endpoint: validate and render without executing.
QueryRangePreview(rw http.ResponseWriter, req *http.Request)
QueryRawStream(rw http.ResponseWriter, req *http.Request)
ReplaceVariables(rw http.ResponseWriter, req *http.Request)
}

338
pkg/querier/preview.go Normal file
View File

@@ -0,0 +1,338 @@
package querier
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// QueryRangePreview validates and renders each query without executing it.
// When opts.Verbose, it also attaches each statement's EXPLAIN ESTIMATE and
// granule analysis.
func (q *querier) QueryRangePreview(
ctx context.Context,
orgID valuer.UUID,
req *qbtypes.QueryRangeRequest,
opts qbtypes.QueryRangePreviewOptions,
) (*qbtypes.QueryRangePreviewResponse, error) {
validationOpts, err := req.ValidateRequestScope()
if err != nil {
return nil, err
}
dependencyQueries, err := q.constructTraceOperatorDependencyMap(req.CompositeQuery.Queries)
if err != nil {
return nil, err
}
results := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
prepared := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
missingMetricQuerySet := make(map[string]bool)
for idx := range req.CompositeQuery.Queries {
name := req.CompositeQuery.Queries[idx].GetQueryName()
ps := qbtypes.QueryPreview{Warnings: []string{}, Statements: []qbtypes.PreviewStatement{}}
if vErr := req.CompositeQuery.Queries[idx].Validate(validationOpts...); vErr != nil {
ps.Error = vErr
prepared[name] = ps
continue
}
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
ps.Warnings = append(ps.Warnings, q.adjustStepInterval(env, req.Start, req.End)...)
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End)
if mErr != nil {
// Report this query's error but keep previewing the rest.
ps.Error = mErr
} else {
ps.Warnings = append(ps.Warnings, metricWarnings...)
if len(missingMetricQueries) > 0 {
missingMetricQuerySet[name] = true
if len(metricWarnings) == 0 {
if metricNames := missingMetricNames(env[0]); len(metricNames) > 0 {
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
"query %q references metric(s) %s with no data available; it will return an empty result",
name, strings.Join(metricNames, ", ")))
}
}
}
}
req.CompositeQuery.Queries[idx] = env[0]
prepared[name] = ps
}
skip := make(map[string]bool, len(prepared))
for name, ps := range prepared {
if ps.Error != nil || missingMetricQuerySet[name] {
skip[name] = true
}
}
providers, buildErrs := q.buildPreviewProviders(req, dependencyQueries, missingMetricQuerySet, skip)
// Render each executing query's statement and collect the ClickHouse-bound
// analysis work to run concurrently.
var previewTasks []qbtypes.PreviewTask
for _, query := range req.CompositeQuery.Queries {
name := query.GetQueryName()
ps := prepared[name]
if ps.Error != nil {
results[name] = ps
continue
}
if missingMetricQuerySet[name] {
results[name] = ps
continue
}
if bErr := buildErrs[name]; bErr != nil {
ps.Error = bErr
results[name] = ps
continue
}
provider, ok := providers[name]
if !ok {
if !rendersStandaloneStatement(query.Type) {
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
"query type %q has no standalone statement to preview; it is evaluated from the queries it references", query.Type.StringValue()))
results[name] = ps
continue
}
ps.Error = errors.NewInternalf(errors.CodeInternal, "query produced no provider")
results[name] = ps
continue
}
stmtProvider, ok := provider.(qbtypes.StatementProvider)
if !ok {
ps.Error = errors.NewInternalf(errors.CodeInternal, "query does not support preview")
results[name] = ps
continue
}
stmt, sErr := stmtProvider.Statement(ctx)
if sErr != nil {
ps.Error = sErr
results[name] = ps
continue
}
ps.Warnings = append(ps.Warnings, stmt.Warnings...)
if query.Type == qbtypes.QueryTypeClickHouseSQL {
if bindErr := q.telemetryStore.Plan(ctx, stmt.Query, stmt.Args...); bindErr != nil {
if errors.Ast(bindErr, errors.TypeInvalidInput) || errors.Ast(bindErr, errors.TypeNotFound) {
ps.Error = bindErr
results[name] = ps
continue
}
ps.Warnings = append(ps.Warnings, "could not validate ClickHouse SQL: "+bindErr.Error())
}
}
if !opts.Verbose {
results[name] = ps
continue
}
if query.Type == qbtypes.QueryTypePromQL {
if pq, ok := provider.(*promqlQuery); ok {
sqlStmts, pErr := pq.PreviewStatements(ctx)
if pErr != nil {
ps.Warnings = append(ps.Warnings, "could not render underlying ClickHouse SQL: "+pErr.Error())
} else {
for _, s := range sqlStmts {
ps.Statements = append(ps.Statements, qbtypes.PreviewStatement{Query: s.Query, Args: orEmpty(s.Args), Estimate: []telemetrystoretypes.EstimateEntry{}})
}
}
}
} else {
ps.Statements = []qbtypes.PreviewStatement{{Query: stmt.Query, Args: orEmpty(stmt.Args), Estimate: []telemetrystoretypes.EstimateEntry{}}}
}
results[name] = ps
for j := range ps.Statements {
previewTasks = append(previewTasks, qbtypes.PreviewTask{Name: name, StmtIdx: j, Query: ps.Statements[j].Query, Args: ps.Statements[j].Args})
}
}
q.runPreviewTasks(ctx, previewTasks, results)
return &qbtypes.QueryRangePreviewResponse{
CompositeQuery: results,
}, nil
}
// missingMetricNames returns the distinct metric names referenced by a metric
// builder query, or nil for a non-metric query.
func missingMetricNames(env qbtypes.QueryEnvelope) []string {
spec, ok := env.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
if !ok {
return nil
}
names := make([]string, 0, len(spec.Aggregations))
for _, agg := range spec.Aggregations {
if agg.MetricName != "" && !slices.Contains(names, agg.MetricName) {
names = append(names, agg.MetricName)
}
}
return names
}
func (q *querier) buildPreviewProviders(
req *qbtypes.QueryRangeRequest,
dependencyQueries map[string]bool,
missingMetricQuerySet map[string]bool,
skip map[string]bool,
) (providers map[string]qbtypes.Query, errs map[string]error) {
providers = make(map[string]qbtypes.Query)
errs = make(map[string]error)
event := &qbtypes.QBEvent{} // preview emits no analytics
for _, query := range req.CompositeQuery.Queries {
name := query.GetQueryName()
if skip[name] {
continue
}
sub := *req // shallow copy: only CompositeQuery and RequestType are swapped
// deps is the set buildQueries skips: empty for a standalone query, the
// operator's referenced siblings for a trace operator.
var deps map[string]bool
switch {
case query.GetType() == qbtypes.QueryTypeTraceOperator:
refs, rErr := q.traceOperatorPreviewComposite(req, query)
if rErr != nil {
errs[name] = rErr
continue
}
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: refs}
deps = dependencyQueries
case dependencyQueries[name]:
sub.RequestType = qbtypes.RequestTypeRaw
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
default:
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
}
built, _, bErr := q.buildQueries(&sub, deps, missingMetricQuerySet, event)
if bErr != nil {
errs[name] = bErr
continue
}
if provider, ok := built[name]; ok {
providers[name] = provider
}
}
return providers, errs
}
// rendersStandaloneStatement reports whether a query type renders its own
// statement. Formula/join/sub-query don't — they reference other queries.
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
switch t {
case qbtypes.QueryTypeBuilder,
qbtypes.QueryTypePromQL,
qbtypes.QueryTypeClickHouseSQL,
qbtypes.QueryTypeTraceOperator:
return true
default:
return false
}
}
func (q *querier) traceOperatorPreviewComposite(req *qbtypes.QueryRangeRequest, operator qbtypes.QueryEnvelope) ([]qbtypes.QueryEnvelope, error) {
spec, ok := operator.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", operator.Spec)
}
if err := spec.ParseExpression(); err != nil {
return nil, err
}
referenced := make(map[string]bool)
for _, name := range spec.CollectReferencedQueries(spec.ParsedExpression) {
referenced[name] = true
}
queries := make([]qbtypes.QueryEnvelope, 0, len(referenced)+1)
for _, qe := range req.CompositeQuery.Queries {
if referenced[qe.GetQueryName()] {
queries = append(queries, qe)
}
}
return append(queries, operator), nil
}
func (q *querier) runPreviewTasks(ctx context.Context, tasks []qbtypes.PreviewTask, previews map[string]qbtypes.QueryPreview) {
if len(tasks) == 0 {
return
}
type outcome struct {
granules *telemetrystoretypes.Granules
estimate []telemetrystoretypes.EstimateEntry
warnings []string
}
outcomes := make([]outcome, len(tasks))
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
t := tasks[i]
var out outcome
if granules, ok, scErr := q.telemetryStore.Indexes(ctx, t.Query, t.Args...); scErr != nil {
out.warnings = append(out.warnings, "could not compute granule stats: "+scErr.Error())
} else if ok {
out.granules = &granules
}
if estimate, eErr := q.telemetryStore.Estimate(ctx, t.Query, t.Args...); eErr != nil {
out.warnings = append(out.warnings, "could not run EXPLAIN ESTIMATE: "+eErr.Error())
} else {
out.estimate = estimate
}
outcomes[i] = out
}(i)
}
wg.Wait()
for i := range tasks {
ps := previews[tasks[i].Name]
if idx := tasks[i].StmtIdx; idx >= 0 && idx < len(ps.Statements) {
if outcomes[i].granules != nil {
ps.Statements[idx].Granules = outcomes[i].granules
}
if len(outcomes[i].estimate) > 0 {
ps.Statements[idx].Estimate = outcomes[i].estimate
}
}
ps.Warnings = append(ps.Warnings, outcomes[i].warnings...)
previews[tasks[i].Name] = ps
}
}
// orEmpty returns s, or a non-nil empty slice when s is nil.
func orEmpty[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}

View File

@@ -101,6 +101,7 @@ type promqlQuery struct {
}
var _ qbv5.Query = (*promqlQuery)(nil)
var _ qbv5.StatementProvider = (*promqlQuery)(nil)
func newPromqlQuery(
logger *slog.Logger,
@@ -220,6 +221,62 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
return newQuery.String(), nil
}
// Statement renders the PromQL string (no SQL args) without executing it, for
// the preview path.
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
return &qbv5.Statement{Query: rendered}, nil
}
// PreviewStatements returns the ClickHouse statement(s) this PromQL query would
// run, captured by driving the engine with a Storage that records each selector's
// SQL and returns no data. Returns nil if capture is unsupported.
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
storer, ok := q.promEngine.(prometheus.StatementCapturer)
if !ok {
return nil, nil
}
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
start := int64(querybuilder.ToNanoSecs(q.tr.From))
end := int64(querybuilder.ToNanoSecs(q.tr.To))
capStorage, recorder := storer.CapturingStorage()
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
capStorage,
nil,
rendered,
time.Unix(0, start),
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
if e := tryEnhancePromQLExecError(err); e != nil {
return nil, e
}
return nil, enhancePromQLError(rendered, err)
}
defer qry.Close()
// Exec drives a Select per selector (recording SQL) but reads no data.
if res := qry.Exec(ctx); res.Err != nil {
if e := tryEnhancePromQLExecError(res.Err); e != nil {
return nil, e
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
return recorder.Statements(), nil
}
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{

View File

@@ -23,6 +23,7 @@ type traceOperatorQuery struct {
}
var _ qbtypes.Query = (*traceOperatorQuery)(nil)
var _ qbtypes.StatementProvider = (*traceOperatorQuery)(nil)
func (q *traceOperatorQuery) Fingerprint() string {
return ""
@@ -32,6 +33,11 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
return q.fromMS, q.toMS
}
// Statement renders the SQL without executing it, for the preview path.
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
}
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
stmt, err := q.stmtBuilder.Build(
ctx,

View File

@@ -145,7 +145,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
"Found %d syntax errors while parsing the search expression.",
len(parserErrorListener.SyntaxErrors),
)
additionals := make([]string, len(parserErrorListener.SyntaxErrors))
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
for _, err := range parserErrorListener.SyntaxErrors {
if err.Error() != "" {
additionals = append(additionals, err.Error())

View File

@@ -0,0 +1,218 @@
package clickhousetelemetrystore
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
// ExplainPlanNode is a node in ClickHouse's `EXPLAIN json = 1, indexes = 1`
// output, parsed to derive the granule-skip breakdown.
type ExplainPlanNode struct {
NodeType string `json:"Node Type"`
Description string `json:"Description"`
Indexes []ExplainPlanIndex `json:"Indexes"`
Plans []ExplainPlanNode `json:"Plans"`
}
// ExplainPlanIndex is one index entry under a ReadFromMergeTree node, reporting
// the parts/granules entering and surviving the index.
type ExplainPlanIndex struct {
Type string `json:"Type"`
Name string `json:"Name"`
Keys []string `json:"Keys"`
Condition string `json:"Condition"`
InitialParts *int64 `json:"Initial Parts"`
SelectedParts *int64 `json:"Selected Parts"`
InitialGranules *int64 `json:"Initial Granules"`
SelectedGranules *int64 `json:"Selected Granules"`
}
// RunExplainEstimate backs TelemetryStore.Estimate.
func RunExplainEstimate(ctx context.Context, conn clickhouse.Conn, stmt string, args ...any) ([]telemetrystoretypes.EstimateEntry, error) {
if err := ValidateExplainStatement(stmt); err != nil {
return nil, err
}
rows, err := conn.Query(ctx, "EXPLAIN ESTIMATE "+stmt, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN ESTIMATE")
}
defer rows.Close()
colTypes := rows.ColumnTypes()
var entries []telemetrystoretypes.EstimateEntry
for rows.Next() {
dest := make([]any, len(colTypes))
for i, ct := range colTypes {
dest[i] = reflect.New(ct.ScanType()).Interface()
}
if err := rows.Scan(dest...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN ESTIMATE row")
}
var entry telemetrystoretypes.EstimateEntry
for i, ct := range colTypes {
val := reflect.ValueOf(dest[i]).Elem().Interface()
switch strings.ToLower(ct.Name()) {
case "database":
entry.Database = fmt.Sprintf("%v", val)
case "table":
entry.Table = fmt.Sprintf("%v", val)
case "parts":
entry.Parts = toInt64(val)
case "rows":
entry.Rows = toInt64(val)
case "marks":
entry.Marks = toInt64(val)
}
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN ESTIMATE row iteration failed")
}
return entries, nil
}
// RunExplainPlan backs TelemetryStore.Plan, returning the driver error when stmt
// does not parse or bind.
func RunExplainPlan(ctx context.Context, conn clickhouse.Conn, stmt string, args ...any) error {
if err := ValidateExplainStatement(stmt); err != nil {
return err
}
rows, err := conn.Query(ctx, "EXPLAIN PLAN "+stmt, args...)
if err != nil {
return err
}
rows.Close()
return nil
}
// RunExplainIndexes backs TelemetryStore.Indexes, summing the breakdown
// across every ReadFromMergeTree node.
func RunExplainIndexes(ctx context.Context, conn clickhouse.Conn, stmt string, args ...any) (telemetrystoretypes.Granules, bool, error) {
if err := ValidateExplainStatement(stmt); err != nil {
return telemetrystoretypes.Granules{}, false, err
}
rows, err := conn.Query(ctx, "EXPLAIN json = 1, indexes = 1 "+stmt, args...)
if err != nil {
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN for granule stats")
}
defer rows.Close()
// json=1 emits one JSON document; join rows in case the driver splits it.
var sb strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN json row")
}
sb.WriteString(line)
sb.WriteByte('\n')
}
if err := rows.Err(); err != nil {
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN json row iteration failed")
}
var plans []struct {
Plan ExplainPlanNode `json:"Plan"`
}
if err := json.Unmarshal([]byte(sb.String()), &plans); err != nil {
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse EXPLAIN json")
}
var totalInitial, totalSelected int64
reads := []telemetrystoretypes.MergeTreeRead{}
for i := range plans {
collectMergeTreeReads(&plans[i].Plan, &reads, &totalInitial, &totalSelected)
}
if totalInitial <= 0 {
// No MergeTree index analysis — nothing to report.
return telemetrystoretypes.Granules{}, false, nil
}
if totalSelected < 0 {
totalSelected = 0
}
skippedGranules := totalInitial - totalSelected
if skippedGranules < 0 {
skippedGranules = 0
}
return telemetrystoretypes.Granules{
Initial: totalInitial,
Selected: totalSelected,
Skipped: skippedGranules,
Reads: reads,
}, true, nil
}
func collectMergeTreeReads(node *ExplainPlanNode, reads *[]telemetrystoretypes.MergeTreeRead, totalInitial, totalSelected *int64) {
if node.NodeType == "ReadFromMergeTree" && len(node.Indexes) > 0 {
steps := make([]telemetrystoretypes.IndexStep, 0, len(node.Indexes))
var initial, selected *int64
for i := range node.Indexes {
idx := node.Indexes[i]
if idx.InitialGranules != nil && initial == nil {
initial = idx.InitialGranules
}
if idx.SelectedGranules != nil {
selected = idx.SelectedGranules
}
steps = append(steps, telemetrystoretypes.IndexStep{
Type: idx.Type,
Name: idx.Name,
Keys: orEmpty(idx.Keys),
Condition: idx.Condition,
InitialParts: derefInt64(idx.InitialParts),
SelectedParts: derefInt64(idx.SelectedParts),
InitialGranules: derefInt64(idx.InitialGranules),
SelectedGranules: derefInt64(idx.SelectedGranules),
})
}
if initial != nil && selected != nil {
*totalInitial += *initial
*totalSelected += *selected
}
*reads = append(*reads, telemetrystoretypes.MergeTreeRead{Table: node.Description, Steps: steps})
}
for i := range node.Plans {
collectMergeTreeReads(&node.Plans[i], reads, totalInitial, totalSelected)
}
}
// toInt64 coerces a driver-scanned numeric value to int64 (0 if non-numeric).
func toInt64(v any) int64 {
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return rv.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return int64(rv.Uint())
case reflect.Float32, reflect.Float64:
return int64(rv.Float())
default:
return 0
}
}
func derefInt64(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
// orEmpty returns s, or a non-nil empty slice when s is nil.
func orEmpty[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}

View File

@@ -0,0 +1,122 @@
package clickhousetelemetrystore
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
// ErrCodeUnsafeStatement is returned when a statement is not a single statement
// safe to wrap in an EXPLAIN prefix.
var ErrCodeUnsafeStatement = errors.MustNewCode("unsafe_statement")
// scanState is the lexer state while scanning a statement.
type scanState int
const (
scanNormal scanState = iota
scanSingle
scanDouble
scanBacktick
scanLineComment
scanBlockComment
)
// ValidateExplainStatement rejects stacked statements (e.g. `SELECT 1; DROP TABLE t`),
// the only injection vector left once values are bound and the EXPLAIN prefix is fixed.
// It scans stmt as ClickHouse SQL — ignoring ';' inside string literals, quoted
// identifiers, and comments — and rejects any content after a top-level ';'.
func ValidateExplainStatement(stmt string) error {
if strings.TrimSpace(stmt) == "" {
return errors.NewInvalidInputf(ErrCodeUnsafeStatement, "statement is empty")
}
state := scanNormal
// terminated is set at a top-level ';'; after it only whitespace, ';', and
// comments may appear — anything else is a second statement.
terminated := false
for i := 0; i < len(stmt); i++ {
c := stmt[i]
switch state {
case scanNormal:
if terminated {
switch {
case isSQLSpace(c) || c == ';':
// harmless trailing whitespace / empty statements
case c == '-' && i+1 < len(stmt) && stmt[i+1] == '-':
state = scanLineComment
i++
case c == '/' && i+1 < len(stmt) && stmt[i+1] == '*':
state = scanBlockComment
i++
default:
return errors.NewInvalidInputf(ErrCodeUnsafeStatement, "statement must be a single statement; content found after ';'")
}
continue
}
switch {
case c == '\'':
state = scanSingle
case c == '"':
state = scanDouble
case c == '`':
state = scanBacktick
case c == '-' && i+1 < len(stmt) && stmt[i+1] == '-':
state = scanLineComment
i++
case c == '/' && i+1 < len(stmt) && stmt[i+1] == '*':
state = scanBlockComment
i++
case c == ';':
terminated = true
}
case scanSingle:
i = skipQuoted(stmt, i, '\'', &state)
case scanDouble:
i = skipQuoted(stmt, i, '"', &state)
case scanBacktick:
i = skipQuoted(stmt, i, '`', &state)
case scanLineComment:
if c == '\n' {
state = scanNormal
}
case scanBlockComment:
if c == '*' && i+1 < len(stmt) && stmt[i+1] == '/' {
state = scanNormal
i++
}
}
}
return nil
}
// skipQuoted advances one character within a quoted literal/identifier delimited
// by quote. A backslash or doubled quote escapes; an unescaped quote ends the
// literal (resetting *state). It returns the index to resume from (caller adds one).
func skipQuoted(s string, i int, quote byte, state *scanState) int {
c := s[i]
switch c {
case '\\':
// skip the escaped character
return i + 1
case quote:
if i+1 < len(s) && s[i+1] == quote {
// doubled quote: stay inside the literal
return i + 1
}
*state = scanNormal
}
return i
}
// isSQLSpace reports whether c is SQL statement whitespace.
func isSQLSpace(c byte) bool {
switch c {
case ' ', '\t', '\n', '\r', '\v', '\f':
return true
default:
return false
}
}

View File

@@ -0,0 +1,46 @@
package clickhousetelemetrystore
import "testing"
func TestValidateExplainStatement(t *testing.T) {
cases := []struct {
name string
stmt string
ok bool
}{
{"simple select", "SELECT 1", true},
{"with cte", "WITH x AS (SELECT 1) SELECT * FROM x", true},
{"trailing semicolon", "SELECT 1;", true},
{"trailing semicolon and space", "SELECT 1; \n", true},
{"double trailing semicolon", "SELECT 1;;", true},
{"trailing line comment", "SELECT 1; -- done", true},
{"trailing block comment", "SELECT 1; /* done */", true},
{"semicolon inside string", "SELECT 'a; b' AS x", true},
{"semicolon inside backtick ident", "SELECT 1 AS `a;b`", true},
{"semicolon inside double-quoted ident", "SELECT 1 AS \"a;b\"", true},
{"semicolon inside line comment", "SELECT 1 -- a; b", true},
{"semicolon inside block comment", "SELECT /* a; b */ 1", true},
{"escaped quote then semicolon in string", "SELECT 'a\\'; DROP' AS x", true},
{"doubled quote then semicolon in string", "SELECT 'a''; DROP' AS x", true},
{"empty", "", false},
{"whitespace only", " \n\t", false},
{"stacked statement", "SELECT 1; DROP TABLE t", false},
{"stacked statement no space", "SELECT 1;DROP TABLE t", false},
{"stacked after string close", "SELECT 'a'; DROP TABLE t", false},
{"stacked string statement", "SELECT 1; 'x'", false},
{"stacked after comment", "SELECT 1; /* c */ SELECT 2", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateExplainStatement(tc.stmt)
if tc.ok && err != nil {
t.Fatalf("expected valid, got error: %v", err)
}
if !tc.ok && err == nil {
t.Fatalf("expected error, got nil")
}
})
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"go.opentelemetry.io/otel/metric"
)
@@ -130,18 +131,31 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
return nil, err
}
return &provider{
p := &provider{
settings: settings,
clickHouseConn: chConn,
cluster: config.Clickhouse.Cluster,
hooks: hooks,
}, nil
}
return p, nil
}
func (p *provider) ClickhouseDB() clickhouse.Conn {
return p
}
func (p *provider) Estimate(ctx context.Context, stmt string, args ...any) ([]telemetrystoretypes.EstimateEntry, error) {
return RunExplainEstimate(ctx, p, stmt, args...)
}
func (p *provider) Plan(ctx context.Context, stmt string, args ...any) error {
return RunExplainPlan(ctx, p, stmt, args...)
}
func (p *provider) Indexes(ctx context.Context, stmt string, args ...any) (telemetrystoretypes.Granules, bool, error) {
return RunExplainIndexes(ctx, p, stmt, args...)
}
func (p *provider) Cluster() string {
return p.cluster
}

View File

@@ -4,14 +4,24 @@ import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
type TelemetryStore interface {
// ClickhouseDB returns the clickhouse database connection.
// ClickhouseDB returns the clickhouse connection, which can also EXPLAIN.
ClickhouseDB() clickhouse.Conn
// Cluster returns the cluster name.
Cluster() string
// Estimate returns the per-table scan estimate from EXPLAIN ESTIMATE.
Estimate(ctx context.Context, stmt string, args ...any) ([]telemetrystoretypes.EstimateEntry, error)
// Plan runs EXPLAIN PLAN to check stmt parses and binds.
Plan(ctx context.Context, stmt string, args ...any) error
// Indexes returns the granule-skip breakdown from EXPLAIN json = 1, indexes = 1.
Indexes(ctx context.Context, stmt string, args ...any) (telemetrystoretypes.Granules, bool, error)
}
type TelemetryStoreHook interface {

View File

@@ -1,10 +1,14 @@
package telemetrystoretest
import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/clickhousetelemetrystore"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
var _ telemetrystore.TelemetryStore = (*Provider)(nil)
@@ -36,6 +40,21 @@ func (p *Provider) Cluster() string {
return "cluster"
}
// Estimate runs EXPLAIN ESTIMATE against the mock connection.
func (p *Provider) Estimate(ctx context.Context, stmt string, args ...any) ([]telemetrystoretypes.EstimateEntry, error) {
return clickhousetelemetrystore.RunExplainEstimate(ctx, p.clickhouseDB.(clickhouse.Conn), stmt, args...)
}
// Plan runs EXPLAIN PLAN against the mock connection.
func (p *Provider) Plan(ctx context.Context, stmt string, args ...any) error {
return clickhousetelemetrystore.RunExplainPlan(ctx, p.clickhouseDB.(clickhouse.Conn), stmt, args...)
}
// Indexes runs EXPLAIN indexes against the mock connection.
func (p *Provider) Indexes(ctx context.Context, stmt string, args ...any) (telemetrystoretypes.Granules, bool, error) {
return clickhousetelemetrystore.RunExplainIndexes(ctx, p.clickhouseDB.(clickhouse.Conn), stmt, args...)
}
// Mock returns the underlying Clickhouse mock instance for setting expectations.
func (p *Provider) Mock() cmock.ClickConnMockCommon {
return p.clickhouseDB

View File

@@ -31,11 +31,13 @@ type AgentReport struct {
type AccountConfig struct {
AWS *AWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type UpdatableAccountConfig struct {
AWS *UpdatableAWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *UpdatableAzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *UpdatableGCPAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type PostableAccount struct {
@@ -48,6 +50,7 @@ type PostableAccountConfig struct {
AgentVersion string
AWS *AWSPostableAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzurePostableAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPPostableAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type Credentials struct {
@@ -66,6 +69,7 @@ type ConnectionArtifact struct {
// required till new providers are added
AWS *AWSConnectionArtifact `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureConnectionArtifact `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPConnectionArtifact `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type GetConnectionArtifactRequest = PostableAccount
@@ -211,6 +215,30 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
}
return &AccountConfig{Azure: &AzureAccountConfig{DeploymentRegion: config.Azure.DeploymentRegion, ResourceGroups: config.Azure.ResourceGroups}}, nil
case CloudProviderTypeGCP:
if config.GCP == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
}
if config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
}
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
return nil, err
}
if len(config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
}
return &AccountConfig{
GCP: &GCPAccountConfig{
DeploymentProjectID: config.GCP.DeploymentProjectID,
ProjectIDs: config.GCP.ProjectIDs,
DeploymentRegion: config.GCP.DeploymentRegion,
},
}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -244,6 +272,30 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
}
return &AccountConfig{Azure: &AzureAccountConfig{ResourceGroups: config.Config.Azure.ResourceGroups}}, nil
case CloudProviderTypeGCP:
if config.Config.GCP == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
}
if err := validateGCPRegion(config.Config.GCP.DeploymentRegion); err != nil {
return nil, err
}
if len(config.Config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
}
if config.Config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
}
return &AccountConfig{
GCP: &GCPAccountConfig{
DeploymentProjectID: config.Config.GCP.DeploymentProjectID,
ProjectIDs: config.Config.GCP.ProjectIDs,
DeploymentRegion: config.Config.GCP.DeploymentRegion,
},
}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -332,15 +384,16 @@ func (config *PostableAccountConfig) SetAgentVersion(agentVersion string) {
// thats why not naming it MarshalJSON(), as it will interfere with default JSON marshalling of AccountConfig struct.
// NOTE: this entertains first non-null provider's config.
func (config *AccountConfig) ToJSON() ([]byte, error) {
if config.AWS != nil {
switch {
case config.AWS != nil:
return json.Marshal(config.AWS)
}
if config.Azure != nil {
case config.Azure != nil:
return json.Marshal(config.Azure)
case config.GCP != nil:
return json.Marshal(config.GCP)
default:
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}
func NewIngestionKeyName(provider CloudProviderType) string {

View File

@@ -50,6 +50,7 @@ type IntegrationConfig struct {
type ProviderIntegrationConfig struct {
AWS *AWSIntegrationConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureIntegrationConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPIntegrationConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
// NewGettableAgentCheckIn constructs a backward-compatible response from an AgentCheckInResponse.

View File

@@ -63,6 +63,7 @@ type StorableCloudIntegrationService struct {
type StorableServiceConfig struct {
AWS *StorableAWSServiceConfig
Azure *StorableAzureServiceConfig
GCP *StorableGCPServiceConfig
}
type StorableAWSServiceConfig struct {
@@ -92,6 +93,15 @@ type StorableAzureMetricsServiceConfig struct {
Enabled bool `json:"enabled"`
}
type StorableGCPServiceConfig struct {
Logs *StorableGCPServiceLogsConfig `json:"logs,omitempty"`
Metrics *StorableGCPServiceMetricsConfig `json:"metrics,omitempty"`
}
type StorableGCPServiceLogsConfig = GCPServiceLogsConfig
type StorableGCPServiceMetricsConfig = GCPServiceMetricsConfig
// Scan scans value from DB.
func (r *StorableAgentReport) Scan(src any) error {
var data []byte
@@ -225,6 +235,30 @@ func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, s
}
return &StorableServiceConfig{Azure: storableAzureServiceConfig}, nil
case CloudProviderTypeGCP:
storableGCPServiceConfig := new(StorableGCPServiceConfig)
if supportedSignals.Logs {
if serviceConfig.GCP.Logs == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "logs config is required for GCP service: %s", serviceID.StringValue())
}
storableGCPServiceConfig.Logs = &StorableGCPServiceLogsConfig{
Enabled: serviceConfig.GCP.Logs.Enabled,
}
}
if supportedSignals.Metrics {
if serviceConfig.GCP.Metrics == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "metrics config is required for GCP service: %s", serviceID.StringValue())
}
storableGCPServiceConfig.Metrics = &StorableGCPServiceMetricsConfig{
Enabled: serviceConfig.GCP.Metrics.Enabled,
}
}
return &StorableServiceConfig{GCP: storableGCPServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -246,6 +280,13 @@ func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse Azure service config JSON")
}
return &StorableServiceConfig{Azure: azureConfig}, nil
case CloudProviderTypeGCP:
gcpConfig := new(StorableGCPServiceConfig)
err := json.Unmarshal([]byte(jsonStr), gcpConfig)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse GCP service config JSON")
}
return &StorableServiceConfig{GCP: gcpConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -266,6 +307,13 @@ func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte,
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize Azure service config to JSON")
}
return jsonBytes, nil
case CloudProviderTypeGCP:
jsonBytes, err := json.Marshal(config.GCP)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize GCP service config to JSON")
}
return jsonBytes, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())

View File

@@ -11,6 +11,7 @@ var (
// cloud providers.
CloudProviderTypeAWS = CloudProviderType{valuer.NewString("aws")}
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
CloudProviderTypeGCP = CloudProviderType{valuer.NewString("gcp")}
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
)
@@ -21,6 +22,8 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
return CloudProviderTypeAWS, nil
case CloudProviderTypeAzure.StringValue():
return CloudProviderTypeAzure, nil
case CloudProviderTypeGCP.StringValue():
return CloudProviderTypeGCP, nil
default:
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}

View File

@@ -0,0 +1,40 @@
package cloudintegrationtypes
type GCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Project ID where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPPostableAccountConfig = GCPAccountConfig
type UpdatableGCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Compute service region where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ProjectIDs []string `json:"projectIds" required:"true"`
}
type GCPConnectionArtifact struct{}
type GCPIntegrationConfig struct{}
type GCPTelemetryCollectionStrategy struct{}
type GCPServiceConfig struct {
Logs *GCPServiceLogsConfig `json:"logs,omitempty" required:"false"`
Metrics *GCPServiceMetricsConfig `json:"metrics,omitempty" required:"false"`
}
type GCPServiceLogsConfig struct {
Enabled bool `json:"enabled" required:"true"`
}
type GCPServiceMetricsConfig struct {
Enabled bool `json:"enabled" required:"true"`
}

View File

@@ -102,6 +102,51 @@ var (
AzureRegionWestUS = CloudProviderRegion{valuer.NewString("westus")} // West US.
AzureRegionWestUS2 = CloudProviderRegion{valuer.NewString("westus2")} // West US 2.
AzureRegionWestUS3 = CloudProviderRegion{valuer.NewString("westus3")} // West US 3.
// GCP regions.
GCPRegionAfricaSouth1 = CloudProviderRegion{valuer.NewString("africa-south1")} // Johannesburg, South Africa. Africa.
GCPRegionAsiaEast1 = CloudProviderRegion{valuer.NewString("asia-east1")} // Changhua County, Taiwan. APAC.
GCPRegionAsiaEast2 = CloudProviderRegion{valuer.NewString("asia-east2")} // Hong Kong. APAC.
GCPRegionAsiaNortheast1 = CloudProviderRegion{valuer.NewString("asia-northeast1")} // Tokyo, Japan. APAC.
GCPRegionAsiaNortheast2 = CloudProviderRegion{valuer.NewString("asia-northeast2")} // Osaka, Japan. APAC.
GCPRegionAsiaNortheast3 = CloudProviderRegion{valuer.NewString("asia-northeast3")} // Seoul, South Korea. APAC.
GCPRegionAsiaSouth1 = CloudProviderRegion{valuer.NewString("asia-south1")} // Mumbai, India. APAC.
GCPRegionAsiaSouth2 = CloudProviderRegion{valuer.NewString("asia-south2")} // Delhi, India. APAC.
GCPRegionAsiaSoutheast1 = CloudProviderRegion{valuer.NewString("asia-southeast1")} // Jurong West, Singapore. APAC.
GCPRegionAsiaSoutheast2 = CloudProviderRegion{valuer.NewString("asia-southeast2")} // Jakarta, Indonesia. APAC.
GCPRegionAsiaSoutheast3 = CloudProviderRegion{valuer.NewString("asia-southeast3")} // Bangkok, Thailand. APAC.
GCPRegionAustraliaSoutheast1 = CloudProviderRegion{valuer.NewString("australia-southeast1")} // Sydney, Australia. APAC.
GCPRegionAustraliaSoutheast2 = CloudProviderRegion{valuer.NewString("australia-southeast2")} // Melbourne, Australia. APAC.
GCPRegionEuropeCentral2 = CloudProviderRegion{valuer.NewString("europe-central2")} // Warsaw, Poland. Europe.
GCPRegionEuropeNorth1 = CloudProviderRegion{valuer.NewString("europe-north1")} // Hamina, Finland. Europe.
GCPRegionEuropeNorth2 = CloudProviderRegion{valuer.NewString("europe-north2")} // Stockholm, Sweden. Europe.
GCPRegionEuropeSouthwest1 = CloudProviderRegion{valuer.NewString("europe-southwest1")} // Madrid, Spain. Europe.
GCPRegionEuropeWest1 = CloudProviderRegion{valuer.NewString("europe-west1")} // St. Ghislain, Belgium. Europe.
GCPRegionEuropeWest2 = CloudProviderRegion{valuer.NewString("europe-west2")} // London, England. Europe.
GCPRegionEuropeWest3 = CloudProviderRegion{valuer.NewString("europe-west3")} // Frankfurt, Germany. Europe.
GCPRegionEuropeWest4 = CloudProviderRegion{valuer.NewString("europe-west4")} // Eemshaven, Netherlands. Europe.
GCPRegionEuropeWest6 = CloudProviderRegion{valuer.NewString("europe-west6")} // Zurich, Switzerland. Europe.
GCPRegionEuropeWest8 = CloudProviderRegion{valuer.NewString("europe-west8")} // Milan, Italy. Europe.
GCPRegionEuropeWest9 = CloudProviderRegion{valuer.NewString("europe-west9")} // Paris, France. Europe.
GCPRegionEuropeWest10 = CloudProviderRegion{valuer.NewString("europe-west10")} // Berlin, Germany. Europe.
GCPRegionEuropeWest12 = CloudProviderRegion{valuer.NewString("europe-west12")} // Turin, Italy. Europe.
GCPRegionMECentral1 = CloudProviderRegion{valuer.NewString("me-central1")} // Doha, Qatar. Middle East.
GCPRegionMECentral2 = CloudProviderRegion{valuer.NewString("me-central2")} // Dammam, Saudi Arabia. Middle East.
GCPRegionMEWest1 = CloudProviderRegion{valuer.NewString("me-west1")} // Tel Aviv, Israel. Middle East.
GCPRegionNorthamericaNortheast1 = CloudProviderRegion{valuer.NewString("northamerica-northeast1")} // Montréal, Québec, Canada. North America.
GCPRegionNorthamericaNortheast2 = CloudProviderRegion{valuer.NewString("northamerica-northeast2")} // Toronto, Ontario, Canada. North America.
GCPRegionNorthamericaSouth1 = CloudProviderRegion{valuer.NewString("northamerica-south1")} // Querétaro, Mexico. North America.
GCPRegionSouthamericaEast1 = CloudProviderRegion{valuer.NewString("southamerica-east1")} // Osasco, São Paulo, Brazil. South America.
GCPRegionSouthamericaWest1 = CloudProviderRegion{valuer.NewString("southamerica-west1")} // Santiago, Chile. South America.
GCPRegionUSCentral1 = CloudProviderRegion{valuer.NewString("us-central1")} // Council Bluffs, Iowa. North America.
GCPRegionUSEast1 = CloudProviderRegion{valuer.NewString("us-east1")} // Moncks Corner, South Carolina. North America.
GCPRegionUSEast4 = CloudProviderRegion{valuer.NewString("us-east4")} // Ashburn, Virginia. North America.
GCPRegionUSEast5 = CloudProviderRegion{valuer.NewString("us-east5")} // Columbus, Ohio. North America.
GCPRegionUSSouth1 = CloudProviderRegion{valuer.NewString("us-south1")} // Dallas, Texas. North America.
GCPRegionUSWest1 = CloudProviderRegion{valuer.NewString("us-west1")} // The Dalles, Oregon. North America.
GCPRegionUSWest2 = CloudProviderRegion{valuer.NewString("us-west2")} // Los Angeles, California. North America.
GCPRegionUSWest3 = CloudProviderRegion{valuer.NewString("us-west3")} // Salt Lake City, Utah. North America.
GCPRegionUSWest4 = CloudProviderRegion{valuer.NewString("us-west4")} // Las Vegas, Nevada. North America.
)
func Enum() []any {
@@ -127,6 +172,18 @@ func Enum() []any {
AzureRegionSwedenCentral, AzureRegionSwitzerlandNorth, AzureRegionSwitzerlandWest,
AzureRegionUAECentral, AzureRegionUAENorth, AzureRegionUKSouth, AzureRegionUKWest,
AzureRegionWestCentralUS, AzureRegionWestEurope, AzureRegionWestIndia, AzureRegionWestUS, AzureRegionWestUS2, AzureRegionWestUS3,
// GCP regions.
GCPRegionAfricaSouth1, GCPRegionAsiaEast1, GCPRegionAsiaEast2, GCPRegionAsiaNortheast1, GCPRegionAsiaNortheast2, GCPRegionAsiaNortheast3,
GCPRegionAsiaSouth1, GCPRegionAsiaSouth2, GCPRegionAsiaSoutheast1, GCPRegionAsiaSoutheast2, GCPRegionAsiaSoutheast3,
GCPRegionAustraliaSoutheast1, GCPRegionAustraliaSoutheast2,
GCPRegionEuropeCentral2, GCPRegionEuropeNorth1, GCPRegionEuropeNorth2, GCPRegionEuropeSouthwest1,
GCPRegionEuropeWest1, GCPRegionEuropeWest2, GCPRegionEuropeWest3, GCPRegionEuropeWest4, GCPRegionEuropeWest6,
GCPRegionEuropeWest8, GCPRegionEuropeWest9, GCPRegionEuropeWest10, GCPRegionEuropeWest12,
GCPRegionMECentral1, GCPRegionMECentral2, GCPRegionMEWest1,
GCPRegionNorthamericaNortheast1, GCPRegionNorthamericaNortheast2, GCPRegionNorthamericaSouth1,
GCPRegionSouthamericaEast1, GCPRegionSouthamericaWest1,
GCPRegionUSCentral1, GCPRegionUSEast1, GCPRegionUSEast4, GCPRegionUSEast5, GCPRegionUSSouth1,
GCPRegionUSWest1, GCPRegionUSWest2, GCPRegionUSWest3, GCPRegionUSWest4,
}
}
@@ -154,6 +211,19 @@ var SupportedRegions = map[CloudProviderType][]CloudProviderRegion{
AzureRegionUAECentral, AzureRegionUAENorth, AzureRegionUKSouth, AzureRegionUKWest,
AzureRegionWestCentralUS, AzureRegionWestEurope, AzureRegionWestIndia, AzureRegionWestUS, AzureRegionWestUS2, AzureRegionWestUS3,
},
CloudProviderTypeGCP: {
GCPRegionAfricaSouth1, GCPRegionAsiaEast1, GCPRegionAsiaEast2, GCPRegionAsiaNortheast1, GCPRegionAsiaNortheast2, GCPRegionAsiaNortheast3,
GCPRegionAsiaSouth1, GCPRegionAsiaSouth2, GCPRegionAsiaSoutheast1, GCPRegionAsiaSoutheast2, GCPRegionAsiaSoutheast3,
GCPRegionAustraliaSoutheast1, GCPRegionAustraliaSoutheast2,
GCPRegionEuropeCentral2, GCPRegionEuropeNorth1, GCPRegionEuropeNorth2, GCPRegionEuropeSouthwest1,
GCPRegionEuropeWest1, GCPRegionEuropeWest2, GCPRegionEuropeWest3, GCPRegionEuropeWest4, GCPRegionEuropeWest6,
GCPRegionEuropeWest8, GCPRegionEuropeWest9, GCPRegionEuropeWest10, GCPRegionEuropeWest12,
GCPRegionMECentral1, GCPRegionMECentral2, GCPRegionMEWest1,
GCPRegionNorthamericaNortheast1, GCPRegionNorthamericaNortheast2, GCPRegionNorthamericaSouth1,
GCPRegionSouthamericaEast1, GCPRegionSouthamericaWest1,
GCPRegionUSCentral1, GCPRegionUSEast1, GCPRegionUSEast4, GCPRegionUSEast5, GCPRegionUSSouth1,
GCPRegionUSWest1, GCPRegionUSWest2, GCPRegionUSWest3, GCPRegionUSWest4,
},
}
func validateAWSRegion(region string) error {
@@ -175,3 +245,13 @@ func validateAzureRegion(region string) error {
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid Azure region: %s", region)
}
func validateGCPRegion(region string) error {
for _, r := range SupportedRegions[CloudProviderTypeGCP] {
if r.StringValue() == region {
return nil
}
}
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid GCP region: %s", region)
}

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