Compare commits

...

30 Commits

Author SHA1 Message Date
Naman Verma
79143be510 Revert "Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation"
This reverts commit e91785bfe3, reversing
changes made to fe838a3464.
2026-06-30 21:48:14 +05:30
Naman Verma
e91785bfe3 Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation 2026-06-30 21:41:37 +05:30
Naman Verma
fe838a3464 Merge branch 'main' into nv/dashboard-create-aggregation 2026-06-30 21:40:48 +05:30
Naman Verma
ff22facdd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-30 21:40:36 +05:30
Naman Verma
875432d7ec test: add integration test 2026-06-30 21:34:17 +05:30
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
Naman Verma
5f1e7a3a53 chore: remove unused err type 2026-06-30 19:15:31 +05:30
Naman Verma
6a4629a418 fix: handle multi aggregations in public dashboard panels 2026-06-30 19:03:50 +05:30
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
Ashwin Bhatkal
b2f048770e feat(dashboards-v2): list tags, DSL search, per-user pinning & org-shared views (#11868)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboards-v2): remove temporary V2 list banner

The 'You're on the V2 dashboards page' warning banner was a stop-gap while the
page was in flux. Remove it now that the page is the intended destination.

* feat(dashboards-v2): strict key:value tag input with inline edit

Add a shared TagKeyValueInput that accepts tags only as key:value pairs,
committed on Enter and rejecting bare values, with double-click inline editing,
removable sienna chips, and a key:value parser. Use it on the dashboard settings
Overview form and the create-dashboard modal (replacing the comma-separated text
field). The dashboard list item type adopts the per-user list DTO so rows carry
the data the tag/pin features need.

* feat(dashboards-v2): DSL search with key suggestions and tag filtering

The search box now passes raw filter DSL straight through (parenthesized to keep
its precedence isolated) instead of wrapping the term in name CONTAINS, so users
can type name/tag/created_by clauses directly. A suggestions dropdown offers the
reserved DSL keys plus the tag keys reported by the list endpoint. Adds a Tags
filter chip (multi-select from the API's tags) alongside Created by / Updated.

* feat(dashboards-v2): per-user dashboard pinning

Replace the localStorage favorite star with backend per-user pinning. The row
action pins/unpins via the v2 per-user endpoints (pin/unpin), refreshes the
personalized list, and surfaces the 10-pin limit (HTTP 409) as a toast. Uses the
Pin/PinOff icons.

* feat(dashboards-v2): org-shared saved views via the views API

Persist saved views through the backend Views API (list/create/update/delete)
instead of localStorage, so they are shared across the org. A view stores the
combined DSL query plus sort/order and folds into the search box on select. Wires
the personalized list (pinned-aware), renames the 'Favorites' built-in to
'Pinned', renames the metadata popover to 'Columns', and polishes the views rail
(active accent, left-aligned title).

* refactor(dashboards-v2): group list utils under a utils/ folder

Move filterQuery, dslSuggestions, views and the general helpers (plus their tests)
into DashboardsListPageV2/utils/ so the page's growing set of non-component modules
lives in one place; update import paths. No behavior change.

* feat(dashboards-v2): retain tags across refetches + DSL keyboard nav

Accumulate the tags reported by the list endpoint across refetches so previously-
seen tags stay selectable in the Tags chip and DSL suggestions even when a filtered
page omits them. The DSL suggestions dropdown is now keyboard-navigable (arrow keys
+ Enter, Escape to close) and renders as a seamless full-width panel attached to the
input.

* refactor(dashboards-v2): prefer @signozhq Button/Typography, polish views rail

Replace raw <button>/<span> in the V2 list and tag input with @signozhq Button and
Typography, and apply icon classes directly to the icon (no wrapper span). Switch the
pin row to Pin/PinOff. Views rail: larger view-name font, 'Filter views by name'
placeholder, and an editable tag chip using a flattened ghost Button.

* chore(dashboards-v2): fix formatting in moved util files

* refactor(dashboards-v2): use an enum for built-in view ids

Replace the BuiltinViewId string-literal union with a TS enum (string values
unchanged, so the URL view param stays compatible) and use its members across the
view catalogue, snapshots, and the All-view check.

* refactor(dashboards-v2): trim redundant tag-chip remove-button overrides

Ghost background is already transparent and the hover colors are not needed; keep
only the size overrides plus color:inherit so the close icon matches the chip sienna.

* fix(dashboards-v2): don't open the dashboard when cancelling delete

The confirm dialog renders through this component's contextHolder, which sits inside
the row's click-to-open handler; React replays portal events up the tree, so the
Cancel click bubbled to the row and navigated. Stop propagation on cancel.

* fix(dashboards-v2): blend the views-rail delete action into the row

Revert the delete action to a ghost icon that stays transparent over the row's hover
background and only turns red on its own hover (the outlined variant broke the
unified background), stop its click from selecting the view, and restore the
content-driven row height so all rows stay uniform.

* fix(dashboards-v2): overlay the views-rail delete instead of reserving space

Absolutely position the delete action on the row's right edge (created views only)
so it no longer takes flex space, and drop the --button-height:auto hack so the row
keeps its natural height. pointer-events are gated so the hidden button can't
intercept row clicks.

* fix(dashboards-v2): align tag chip weight and fix remove-button hover

Match the chip text weight to the list-row badge (normal, not medium) so the colour
reads the same, and give the remove (×) a sienna-tinted hover instead of the Button's
default grey. Resting × colour stays at the Button default.
2026-06-30 10:35:45 +00:00
Naman Verma
72a6ca6516 Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 20:07:16 +05:30
Naman Verma
bbd5cc380e Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 02:02:16 +05:30
Naman Verma
6cd9b5bbd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 15:37:15 +05:30
Naman Verma
13f6c232a1 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 11:47:24 +05:30
Naman Verma
dac1489294 Merge branch 'main' into nv/v2-public-dashboard 2026-06-17 07:29:31 +05:30
Naman Verma
1d98e9ebf6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-16 12:35:00 +05:30
Naman Verma
15e99e43ff test: add integration tests for new v2 public apis 2026-06-16 12:21:50 +05:30
Naman Verma
8c766f8c10 fix: generate api specs 2026-06-16 11:45:22 +05:30
Naman Verma
99b32f00b9 fix: add fill gaps to query 2026-06-16 02:30:59 +05:30
Naman Verma
76f8646c69 test: unit tests for GetPanelQuery 2026-06-16 02:18:01 +05:30
Naman Verma
28c00e298a chore: rename method name 2026-06-16 02:01:40 +05:30
Naman Verma
4592b12256 fix: remove fields that v1 also removes when redacting 2026-06-16 01:53:33 +05:30
Naman Verma
b990d40c5f chore: trim comments 2026-06-16 00:23:41 +05:30
Naman Verma
95a0d7c035 fix: fill fields that were in the data blob in v1 2026-06-15 23:04:21 +05:30
Naman Verma
e678728c61 fix: remove duplicate call to GetDashboardByPublicIDV2 in GetPublicWidgetQueryRangeV2 2026-06-15 22:31:45 +05:30
Naman Verma
42d3e7e0e4 feat: add first draft of v2 public dashboard apis 2026-06-15 15:00:44 +05:30
106 changed files with 4942 additions and 2623 deletions

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

@@ -6212,6 +6212,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 +6551,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 +7935,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 +23556,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

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

@@ -7555,6 +7555,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 +7756,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 +11665,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

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

@@ -0,0 +1,90 @@
.container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// Sienna chip — matches the dashboard list-row tag badge.
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
height: 24px;
padding: 2px 4px 2px 8px;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: 20px;
cursor: text;
}
.tagLabel {
--button-height: auto;
--button-padding: 0;
--button-gap: 0;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: inherit;
--button-variant-ghost-hover-color: inherit;
overflow: hidden;
max-width: 200px;
font-size: 13px;
font-weight: var(--font-weight-normal);
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.remove {
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
// default ghost hover is a grey that clashes with the chip. Resting color is
// left at the Button default.
--button-height: 16px;
--button-padding: 0;
--button-border-radius: 50%;
--button-variant-ghost-hover-background-color: color-mix(
in srgb,
var(--bg-sienna-500) 22%,
transparent
);
--button-variant-ghost-hover-color: var(--bg-sienna-400);
width: 16px;
min-width: 16px;
flex: none;
}
.input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
&::placeholder {
color: var(--l3-foreground);
}
}
.editInput {
width: 160px;
height: 24px;
}
.error {
color: var(--bg-cherry-500);
font-size: 12px;
}

View File

@@ -0,0 +1,164 @@
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import cx from 'classnames';
import { parseKeyValueTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
interface TagKeyValueInputProps {
// Tags as `key:value` strings.
tags: string[];
onTagsChange: (tags: string[]) => void;
placeholder?: string;
// Override the outer container styling per host (e.g. the create modal).
className?: string;
testId?: string;
}
// Strict key:value tag editor. A tag is committed only on Enter and only when
// it parses to a valid `key:value` pair — bare values are rejected with an
// inline error. Existing chips can be edited inline (double-click), and removed.
function TagKeyValueInput({
tags,
onTagsChange,
placeholder = 'key:value',
className,
testId = 'tag-key-value-input',
}: TagKeyValueInputProps): JSX.Element {
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState('');
const [editIndex, setEditIndex] = useState(-1);
const [editValue, setEditValue] = useState('');
const removeTag = (tag: string): void => {
onTagsChange(tags.filter((t) => t !== tag));
};
const commit = (): void => {
const raw = inputValue.trim();
if (!raw) {
return;
}
const normalized = parseKeyValueTag(raw);
if (!normalized) {
setError('Tags must be in key:value format (both sides required).');
return;
}
if (tags.includes(normalized)) {
setError('This tag already exists.');
return;
}
onTagsChange([...tags, normalized]);
setInputValue('');
setError('');
};
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value);
if (error) {
setError('');
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
};
const startEdit = (index: number): void => {
setEditIndex(index);
setEditValue(tags[index]);
setError('');
};
const cancelEdit = (): void => {
setEditIndex(-1);
setEditValue('');
};
const commitEdit = (): void => {
const normalized = parseKeyValueTag(editValue);
// Drop into a no-op (revert) on invalid or duplicate edits rather than
// stranding the user in an un-exitable edit box.
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
}
cancelEdit();
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
return (
<div className={cx(styles.container, className)}>
<div className={styles.field}>
{tags.map((tag, index) =>
index === editIndex ? (
<Input
key={tag}
className={styles.editInput}
value={editValue}
autoFocus
testId={`${testId}-edit`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setEditValue(e.target.value)
}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
/>
) : (
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
<Button
variant="ghost"
color="secondary"
className={styles.tagLabel}
title="Double-click to edit"
onDoubleClick={(): void => startEdit(index)}
>
{tag}
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.remove}
aria-label={`Remove ${tag}`}
onClick={(): void => removeTag(tag)}
>
<X size={12} />
</Button>
</div>
),
)}
<Input
className={styles.input}
value={inputValue}
placeholder={placeholder}
testId={testId}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
</div>
{error && (
<Typography className={styles.error} data-testid={`${testId}-error`}>
{error}
</Typography>
)}
</div>
);
}
export default TagKeyValueInput;

View File

@@ -0,0 +1,31 @@
import { parseKeyValueTag } from './utils';
describe('parseKeyValueTag', () => {
it('normalizes a valid key:value pair', () => {
expect(parseKeyValueTag('env:prod')).toBe('env:prod');
});
it('trims whitespace around key and value', () => {
expect(parseKeyValueTag(' env : prod ')).toBe('env:prod');
});
it('keeps colons inside the value', () => {
expect(parseKeyValueTag('url:http://x')).toBe('url:http://x');
});
it('rejects a bare value with no colon', () => {
expect(parseKeyValueTag('prod')).toBeNull();
});
it('rejects an empty key', () => {
expect(parseKeyValueTag(':prod')).toBeNull();
});
it('rejects an empty value', () => {
expect(parseKeyValueTag('env:')).toBeNull();
});
it('rejects blank input', () => {
expect(parseKeyValueTag(' ')).toBeNull();
});
});

View File

@@ -0,0 +1,17 @@
// Tags are strictly key:value. Parse a raw input into a normalized `key:value`
// string, or null if it isn't a valid pair (both sides non-empty). The first
// colon separates key from value, so values may themselves contain colons
// (e.g. `url:http://x`).
export function parseKeyValueTag(raw: string): string | null {
const trimmed = raw.trim();
const idx = trimmed.indexOf(':');
if (idx <= 0) {
return null;
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!key || !value) {
return null;
}
return `${key}:${value}`;
}

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

@@ -68,13 +68,3 @@
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// the V1 tags input ships borderless; give the field a visible box to match
.tagsField {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
// background: var(--l3-background);
}

View File

@@ -9,7 +9,7 @@ import {
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { Base64Icons } from '../utils';
import settingsStyles from '../../DashboardSettings.module.scss';
@@ -89,9 +89,7 @@ function DashboardInfoForm({
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Tags</Typography>
<div className={styles.tagsField}>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
<TagKeyValueInput tags={tags} onTagsChange={onTagsChange} />
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.

View File

@@ -1,5 +1,3 @@
import { useState } from 'react';
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
import { LayoutGrid } from '@signozhq/icons';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
@@ -9,19 +7,8 @@ import styles from './DashboardsListPageV2.module.scss';
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
function DashboardsListPageV2(): JSX.Element {
const [showBanner, setShowBanner] = useState(true);
return (
<div className={styles.page}>
{showBanner && (
<AnnouncementBanner
type="warning"
onClose={(): void => setShowBanner(false)}
>
You&apos;re on the V2 dashboards page. If you landed here unintentionally,
please reach out to Ashwin.
</AnnouncementBanner>
)}
<div className={styles.header}>
<div className={styles.headerLeft}>
<BreadcrumbLink icon={<LayoutGrid size={14} />}>Dashboard</BreadcrumbLink>

View File

@@ -78,6 +78,11 @@ function DeleteActionItem({
runDelete(undefined, { onSettled: () => destroy() });
},
},
cancelButtonProps: {
onClick: (e): void => {
e.stopPropagation();
},
},
centered: true,
});
}, [modal, dashboardName, runDelete]);

View File

@@ -62,7 +62,7 @@
justify-content: flex-end;
}
.favBtn {
.pinBtn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -79,16 +79,16 @@
color 0.12s;
}
.row:hover .favBtn {
.row:hover .pinBtn {
color: var(--l3-foreground);
}
.favBtn:hover {
.pinBtn:hover {
background: var(--l1-background);
color: var(--bg-amber-500);
}
.favBtnOn {
.pinBtnOn {
color: var(--bg-amber-500);
svg {

View File

@@ -1,7 +1,7 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Badge } from '@signozhq/ui/badge';
import { CalendarClock, Star } from '@signozhq/icons';
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
@@ -12,9 +12,10 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTimezone } from 'providers/Timezone';
import { isModifierKeyPressed } from 'utils/app';
import { usePinDashboard } from '../../hooks/usePinDashboard';
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import type { DashboardListItem } from '../../utils';
import { lastUpdatedLabel, tagsToStrings } from '../../utils';
import type { DashboardListItem } from '../../utils/helpers';
import { lastUpdatedLabel, tagsToStrings } from '../../utils/helpers';
import ActionsPopover from '../ActionsPopover/ActionsPopover';
import styles from './DashboardRow.module.scss';
@@ -37,12 +38,10 @@ function DashboardRow({
const { safeNavigate } = useSafeNavigate();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const isFavorite = useDashboardViewsStore((s) =>
s.favorites.includes(dashboard.id),
);
const toggleFavorite = useDashboardViewsStore((s) => s.toggleFavorite);
const markViewed = useDashboardViewsStore((s) => s.markViewed);
const { togglePin, isUpdating } = usePinDashboard();
const isPinned = !!dashboard.pinned;
const id = dashboard.id;
const name = dashboard.spec?.display?.name ?? '';
const image = dashboard.image || Base64Icons[0];
@@ -69,9 +68,9 @@ function DashboardRow({
});
};
const onToggleFavorite = (event: React.MouseEvent<HTMLElement>): void => {
const onTogglePin = (event: React.MouseEvent<HTMLElement>): void => {
event.stopPropagation();
toggleFavorite(id);
togglePin(id, isPinned);
};
return (
@@ -105,7 +104,7 @@ function DashboardRow({
))}
{tags.length > 3 && (
<Badge className={styles.tag} key={tags[3]}>
+ <span> {tags.length - 3} </span>
+ <Typography.Text> {tags.length - 3} </Typography.Text>
</Badge>
)}
</div>
@@ -114,13 +113,14 @@ function DashboardRow({
<button
type="button"
className={cx(styles.favBtn, { [styles.favBtnOn]: isFavorite })}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
data-testid={`dashboard-favorite-${index}`}
onClick={onToggleFavorite}
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
>
<Star size={14} />
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
</button>
{canAct && (

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import logEvent from 'api/common/logEvent';
import { useListDashboardsV2 } from 'api/generated/services/dashboard';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
@@ -10,7 +10,8 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { toAPIError } from 'utils/errorUtils';
import { combineQueries } from '../../filterQuery';
import { combineQueries } from '../../utils/filterQuery';
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
import { useActiveView } from '../../hooks/useActiveView';
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
import {
@@ -20,9 +21,10 @@ import {
} from '../../hooks/useDashboardsListQueryParams';
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
import type { UpdatedWindow } from '../../types';
import type { DashboardListItem } from '../../utils';
import { applyClientView } from '../../views';
import { BuiltinViewId } from '../../types';
import type { SelectedTag, UpdatedWindow } from '../../types';
import type { DashboardListItem } from '../../utils/helpers';
import { applyClientView } from '../../utils/views';
import type { CreatorOption } from '../FilterZone/FilterChips';
import FilterZone from '../FilterZone/FilterZone';
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
@@ -55,6 +57,7 @@ function DashboardsList(): JSX.Element {
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
} = useDashboardFilters();
@@ -66,6 +69,7 @@ function DashboardsList(): JSX.Element {
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
viewQuery,
@@ -75,11 +79,18 @@ function DashboardsList(): JSX.Element {
saveActiveView,
resetView,
removeView,
} = useActiveView({ filters, applyFilters, userEmail: user.email });
} = useActiveView({
filters,
applyFilters,
userEmail: user.email,
sortColumn,
sortOrder,
setSortColumn,
setSortOrder,
});
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
const favorites = useDashboardViewsStore((s) => s.favorites);
const recent = useDashboardViewsStore((s) => s.recent);
// Any filter change resets to the first page so the user isn't stranded on a
@@ -105,6 +116,13 @@ function DashboardsList(): JSX.Element {
},
[setUpdated, setPage],
);
const handleTagsChange = useCallback(
(tags: SelectedTag[]): void => {
setTags(tags);
void setPage(1);
},
[setTags, setPage],
);
const handleClearAll = useCallback((): void => {
clearAll();
void setPage(1);
@@ -150,7 +168,9 @@ function DashboardsList(): JSX.Element {
isFetching,
error,
refetch,
} = useListDashboardsV2(listParams, { query: { keepPreviousData: true } });
} = useListDashboardsForUserV2(listParams, {
query: { keepPreviousData: true },
});
const apiError = useMemo(
() => (error ? toAPIError(error) : undefined),
@@ -169,9 +189,9 @@ function DashboardsList(): JSX.Element {
const dashboards = useMemo<DashboardListItem[]>(
() =>
clientView
? applyClientView(rawDashboards, activeViewId, favorites, recent)
? applyClientView(rawDashboards, activeViewId, recent)
: rawDashboards,
[clientView, rawDashboards, activeViewId, favorites, recent],
[clientView, rawDashboards, activeViewId, recent],
);
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
@@ -194,6 +214,16 @@ function DashboardsList(): JSX.Element {
}));
}, [rawDashboards, user.email]);
// All key:value tags the API reports for the org's dashboards, powering the
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
// previously-seen tags stay selectable even when a filtered page omits them.
const responseTags = useMemo<SelectedTag[]>(
() =>
(response?.data?.tags ?? []).map((t) => ({ key: t.key, value: t.value })),
[response],
);
const availableTags = useAccumulatedTags(responseTags);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const visibleColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
@@ -239,7 +269,7 @@ function DashboardsList(): JSX.Element {
const showWorkspaceEmpty =
!error &&
dashboards.length === 0 &&
activeViewId === 'all' &&
activeViewId === BuiltinViewId.All &&
filtersEmpty &&
page === 1;
@@ -251,6 +281,7 @@ function DashboardsList(): JSX.Element {
activeViewId={activeViewId}
builtinViews={builtinViews}
customViews={customViews}
customViewsLoading={customViewsLoading}
isCustomActive={isCustomActive}
isModified={isModified}
collapsed={railCollapsed}
@@ -281,11 +312,14 @@ function DashboardsList(): JSX.Element {
search={filters.search}
createdBy={filters.createdBy}
updated={filters.updated}
tags={filters.tags}
availableTags={availableTags}
creatorOptions={creatorOptions}
isEmpty={filtersEmpty}
onSearchChange={handleSearchChange}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onTagsChange={handleTagsChange}
onClearAll={handleClearAll}
/>
</div>

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Table } from 'antd';
import type { TableProps } from 'antd/lib';
import type { DashboardListItem } from '../../utils';
import type { DashboardListItem } from '../../utils/helpers';
import DashboardRow from '../DashboardRow/DashboardRow';
interface Props {

View File

@@ -3,8 +3,8 @@ import {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardListItem } from '../../utils';
import { noResultsCopy } from '../../views';
import type { DashboardListItem } from '../../utils/helpers';
import { noResultsCopy } from '../../utils/views';
import ListHeader from '../ListHeader/ListHeader';
import ErrorState from '../states/ErrorState/ErrorState';
import LoadingState from '../states/LoadingState/LoadingState';

View File

@@ -1,10 +1,19 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import type { UpdatedWindow } from '../../types';
import { buildSuggestionKeys } from '../../utils/dslSuggestions';
import type { SelectedTag, UpdatedWindow } from '../../types';
import SearchBar from '../SearchBar/SearchBar';
import FilterChips, { type CreatorOption } from './FilterChips';
import TagsFilterChip from './TagsFilterChip';
import styles from './FilterZone.module.scss';
@@ -12,11 +21,14 @@ interface Props {
search: string;
createdBy: string[];
updated: UpdatedWindow;
tags: SelectedTag[];
availableTags: SelectedTag[];
creatorOptions: CreatorOption[];
isEmpty: boolean;
onSearchChange: (value: string) => void;
onCreatedByChange: (emails: string[]) => void;
onUpdatedChange: (window: UpdatedWindow) => void;
onTagsChange: (tags: SelectedTag[]) => void;
onClearAll: () => void;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
@@ -29,16 +41,24 @@ function FilterZone({
search,
createdBy,
updated,
tags,
availableTags,
creatorOptions,
isEmpty,
onSearchChange,
onCreatedByChange,
onUpdatedChange,
onTagsChange,
onClearAll,
rightSlot,
}: Props): JSX.Element {
const [searchInput, setSearchInput] = useState(search);
const suggestionKeys = useMemo(
() => buildSuggestionKeys(availableTags),
[availableTags],
);
// Keep the local input in sync with external search changes (applying a view,
// clear-all, back/forward). User typing only mutates the local copy.
useEffect(() => {
@@ -58,7 +78,8 @@ function FilterZone({
<div className={styles.searchInput}>
<SearchBar
value={searchInput}
placeholder="Search dashboards by name"
placeholder={`Search with DSL — e.g. name contains "prod" AND env = "staging"`}
suggestionKeys={suggestionKeys}
onChange={setSearchInput}
onSubmit={handleSubmit}
/>
@@ -66,7 +87,7 @@ function FilterZone({
{rightSlot}
</div>
<div className={styles.filtersRow}>
<span className={styles.filtersLabel}>Filters</span>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
<FilterChips
createdBy={createdBy}
updated={updated}
@@ -74,6 +95,11 @@ function FilterZone({
onCreatedByChange={onCreatedByChange}
onUpdatedChange={onUpdatedChange}
/>
<TagsFilterChip
availableTags={availableTags}
tags={tags}
onTagsChange={onTagsChange}
/>
{!isEmpty && (
<Button
variant="ghost"

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { ChevronDown, Tag } from '@signozhq/icons';
import cx from 'classnames';
import type { SelectedTag } from '../../types';
import styles from './FilterZone.module.scss';
interface Props {
// All key:value tags the list API reports across the org's dashboards.
availableTags: SelectedTag[];
tags: SelectedTag[];
onTagsChange: (tags: SelectedTag[]) => void;
}
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
function TagsFilterChip({
availableTags,
tags,
onTagsChange,
}: Props): JSX.Element {
const selectedIds = useMemo(() => new Set(tags.map(tagId)), [tags]);
const label = useMemo((): string => {
if (tags.length === 0) {
return 'Any';
}
if (tags.length === 1) {
return tagId(tags[0]);
}
return `${tags.length} tags`;
}, [tags]);
const items = useMemo<MenuItem[]>(() => {
const options: MenuItem[] = availableTags.map((tag) => {
const id = tagId(tag);
return {
type: 'checkbox',
key: id,
label: id,
checked: selectedIds.has(id),
onCheckedChange: (checked: boolean): void =>
onTagsChange(
checked ? [...tags, tag] : tags.filter((t) => tagId(t) !== id),
),
};
});
if (tags.length > 0) {
options.push({ type: 'divider', key: 'sep' });
options.push({
key: 'clear',
label: 'Clear selection',
onClick: (): void => onTagsChange([]),
});
}
return options;
}, [availableTags, selectedIds, tags, onTagsChange]);
return (
<DropdownMenuSimple menu={{ items }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<Tag size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, { [styles.chipActive]: tags.length > 0 })}
disabled={availableTags.length === 0}
testId="dashboards-filter-tags"
>
Tags: {label}
</Button>
</DropdownMenuSimple>
);
}
export default TagsFilterChip;

View File

@@ -54,7 +54,7 @@ function ListHeader({
const metadataContent = (
<div className={styles.metaPanel}>
<Typography.Text className={styles.sortHeading}>Metadata</Typography.Text>
<Typography.Text className={styles.sortHeading}>Columns</Typography.Text>
{METADATA_COLUMNS.map((col) => (
<div key={col.key} className={styles.metaRow}>
<Typography.Text className={styles.metaLabel}>{col.label}</Typography.Text>
@@ -171,7 +171,7 @@ function ListHeader({
)
}
>
<span className={styles.sortPrefix}>Sort:</span>{' '}
<Typography.Text className={styles.sortPrefix}>Sort:</Typography.Text>{' '}
{SORT_LABELS[sortColumn]}{' '}
</Button>
</Popover>
@@ -183,13 +183,13 @@ function ListHeader({
placement="bottomRight"
arrow={false}
>
<Tooltip title="Metadata">
<Tooltip title="Columns">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Metadata"
testId="configure-metadata-trigger"
aria-label="Columns"
testId="configure-columns-trigger"
>
<HdmiPort size={14} />
</Button>

View File

@@ -13,8 +13,9 @@ import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { toPostableTags } from '../../utils';
import { keyValueStringsToTags } from '../../utils/helpers';
import styles from './NewDashboardModal.module.scss';
@@ -30,7 +31,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
const [name, setName] = useState(DEFAULT_NAME);
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const canSubmit = name.trim().length > 0 && !submitting;
@@ -42,7 +43,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
try {
setSubmitting(true);
logEvent('Dashboard List: Create dashboard clicked', {});
const postableTags = toPostableTags(tags);
const postableTags = keyValueStringsToTags(tags);
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
@@ -72,7 +73,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
<div className={styles.form}>
<div className={styles.field}>
<Typography.Text className={styles.label}>
Title <span className={styles.required}>*</span>
Title <Typography.Text className={styles.required}>*</Typography.Text>
</Typography.Text>
<Input
value={name}
@@ -104,16 +105,14 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
<div className={styles.field}>
<Typography.Text className={styles.label}>Tags</Typography.Text>
<Input
value={tags}
placeholder="team:jarvis, prod"
<TagKeyValueInput
tags={tags}
onTagsChange={setTags}
placeholder="team:jarvis (press Enter)"
testId="create-dashboard-tags"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setTags(e.target.value)
}
/>
<Typography.Text className={styles.hint}>
Comma-separated. Use key:value (e.g. team:jarvis) or a single label.
Use key:value (e.g. team:jarvis) and press Enter to add.
</Typography.Text>
</div>
</div>

View File

@@ -1,24 +1,58 @@
.submit {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
border-radius: 3px;
color: inherit;
cursor: pointer;
transition: background 120ms ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
outline: none;
}
&:active {
background: color-mix(in srgb, var(--l1-foreground) 20%, transparent);
}
.wrapper {
position: relative;
width: 100%;
}
.input::placeholder {
color: var(--l3-foreground);
}
// Flatten the input's bottom corners while the dropdown is attached below it.
.inputOpen {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 260px;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--l2-border);
border-top: none;
border-radius: 0 0 6px 6px;
background: var(--l1-background);
/* stylelint-disable-next-line local/prefer-css-variables -- matches the V2 dashboard dropdown shadow */
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
}
.suggestion {
display: flex;
align-items: center;
padding: 8px 10px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
font-family: 'Space Mono', monospace;
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.suggestionActive {
background: var(--l2-background);
color: var(--l1-foreground);
}
.submit {
color: var(--bg-vanilla-400);
}

View File

@@ -1,7 +1,21 @@
import { ChangeEvent, KeyboardEvent, MouseEvent } from 'react';
import {
ChangeEvent,
KeyboardEvent,
MouseEvent,
useMemo,
useState,
} from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { CornerDownLeft, Search } from '@signozhq/icons';
import cx from 'classnames';
import {
applyKeySuggestion,
getActiveKeyToken,
matchKeys,
} from '../../utils/dslSuggestions';
import styles from './SearchBar.module.scss';
@@ -10,6 +24,8 @@ interface Props {
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
// Keys offered as you type (reserved DSL columns + tag keys from the API).
suggestionKeys?: string[];
}
function SearchBar({
@@ -17,38 +33,116 @@ function SearchBar({
onChange,
onSubmit,
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
suggestionKeys = [],
}: Props): JSX.Element {
const [focused, setFocused] = useState(false);
// -1 means nothing is highlighted, so Enter submits the typed query rather
// than picking a suggestion (arrow keys engage selection).
const [highlighted, setHighlighted] = useState(-1);
const active = useMemo(() => getActiveKeyToken(value), [value]);
const suggestions = useMemo(
() => (active ? matchKeys(suggestionKeys, active.token) : []),
[active, suggestionKeys],
);
const showSuggestions = focused && suggestions.length > 0;
const pickSuggestion = (key: string): void => {
if (active) {
onChange(applyKeySuggestion(value, active, key));
}
setHighlighted(-1);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (showSuggestions && e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
return;
}
if (showSuggestions && e.key === 'ArrowUp') {
e.preventDefault();
setHighlighted((h) => Math.max(h - 1, 0));
return;
}
if (e.key === 'Enter') {
if (showSuggestions && highlighted >= 0) {
e.preventDefault();
pickSuggestion(suggestions[highlighted]);
} else {
onSubmit();
}
return;
}
if (e.key === 'Escape') {
setFocused(false);
setHighlighted(-1);
}
};
return (
<Input
placeholder={placeholder}
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<button
type="button"
className={styles.submit}
aria-label="Run search"
data-testid="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Prevent the input's blur from firing first and double-submitting.
e.preventDefault();
}}
onClick={onSubmit}
>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</button>
}
value={value}
testId="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
onChange(e.target.value)
}
onBlur={onSubmit}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
onSubmit();
<div className={styles.wrapper}>
<Input
className={cx(styles.input, { [styles.inputOpen]: showSuggestions })}
placeholder={placeholder}
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Prevent the input's blur from firing first and double-submitting.
e.preventDefault();
}}
onClick={onSubmit}
>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</Button>
}
}}
/>
value={value}
testId="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
onChange(e.target.value);
setHighlighted(-1);
}}
onFocus={(): void => setFocused(true)}
onBlur={(): void => {
setFocused(false);
setHighlighted(-1);
onSubmit();
}}
onKeyDown={handleKeyDown}
/>
{showSuggestions && (
<div
className={styles.suggestions}
data-testid="dashboards-list-search-suggestions"
>
{suggestions.map((key, index) => (
<button
key={key}
type="button"
className={cx(styles.suggestion, {
[styles.suggestionActive]: index === highlighted,
})}
data-testid={`dashboards-list-search-suggestion-${key}`}
onMouseEnter={(): void => setHighlighted(index)}
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Keep focus on the input so blur doesn't submit before we update.
e.preventDefault();
}}
onClick={(): void => pickSuggestion(key)}
>
{key}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -2,21 +2,17 @@ import { type ChangeEvent, type ReactNode, useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { PopoverSimple } from '@signozhq/ui/popover';
import cx from 'classnames';
import { VIEW_ICON_OPTIONS } from '../../views';
import { Typography } from '@signozhq/ui/typography';
import styles from './ViewsRail.module.scss';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (name: string, icon: string) => void;
onSave: (name: string) => void;
trigger: ReactNode;
}
const DEFAULT_ICON = VIEW_ICON_OPTIONS[0].name;
function SaveViewPopover({
open,
onOpenChange,
@@ -24,12 +20,10 @@ function SaveViewPopover({
trigger,
}: Props): JSX.Element {
const [name, setName] = useState('');
const [icon, setIcon] = useState(DEFAULT_ICON);
useEffect(() => {
if (open) {
setName('');
setIcon(DEFAULT_ICON);
}
}, [open]);
@@ -37,7 +31,7 @@ function SaveViewPopover({
const handleSave = (): void => {
if (canSave) {
onSave(name, icon);
onSave(name);
onOpenChange(false);
}
};
@@ -51,7 +45,7 @@ function SaveViewPopover({
>
<div className={styles.savePopover}>
<div className={styles.saveTitle}>Save as view</div>
<span className={styles.saveLabel}>Name</span>
<Typography.Text className={styles.saveLabel}>Name</Typography.Text>
<Input
value={name}
autoFocus
@@ -66,22 +60,6 @@ function SaveViewPopover({
}
}}
/>
<span className={styles.saveLabel}>Icon</span>
<div className={styles.iconGrid}>
{VIEW_ICON_OPTIONS.map(({ name: iconName, Icon }) => (
<button
key={iconName}
type="button"
aria-label={iconName}
className={cx(styles.iconCell, {
[styles.iconCellOn]: icon === iconName,
})}
onClick={(): void => setIcon(iconName)}
>
<Icon size={14} />
</button>
))}
</div>
<div className={styles.saveActions}>
<Button
variant="ghost"

View File

@@ -28,6 +28,7 @@
font-weight: var(--font-weight-semibold);
letter-spacing: -0.01em;
color: var(--l1-foreground);
text-align: left;
}
.search {
@@ -76,7 +77,13 @@
padding: 1px 6px;
}
.deleteName {
color: var(--danger-background);
font-weight: var(--font-weight-medium);
}
.row {
position: relative;
display: flex;
align-items: center;
margin: 3px 0;
@@ -92,27 +99,27 @@
background: var(--l2-background);
}
// A left accent bar reads the active row more clearly than background alone.
.rowActive::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
border-radius: 0 3px 3px 0;
background: var(--primary-background);
}
.item {
// Neutralise the signoz Button defaults so it reads as a full-width,
// left-aligned list row; the row coordinates hover/active colours below.
--button-display: flex;
--button-justify-content: flex-start;
--button-height: auto;
--button-padding: 9px 10px;
--button-gap: 10px;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: var(--l2-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
flex: 1;
min-width: 0;
width: 100%;
font-size: var(--font-size-sm);
}
.row:hover .item,
.rowActive .item {
--button-variant-ghost-color: var(--l1-foreground);
font-size: var(--font-size-base);
}
.itemIcon {
@@ -125,7 +132,6 @@
}
.itemLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -140,24 +146,31 @@
}
.itemAction {
// Square icon button that surfaces on row hover and turns red on its own
// hover; colours flow through the signoz Button tokens.
--button-height: auto;
// Blended ghost icon overlaid on the row's right edge — absolutely positioned
// so it never reserves layout space or affects the row height. Transparent so
// the row's hover background shows through; turns red only on its own hover.
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
--button-height: 20px;
--button-padding: 0;
--button-border-radius: 4px;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-background-color: var(--danger-background);
--button-variant-ghost-hover-color: var(--danger-color, #fff);
width: 20px;
height: 20px;
margin-right: 8px;
opacity: 0;
// Hidden until row hover, and inert while hidden so it can't intercept clicks
// meant for the row.
pointer-events: none;
transition: opacity 0.1s;
}
.row:hover .itemAction {
opacity: 1;
pointer-events: auto;
}
.empty {
@@ -217,37 +230,6 @@
margin-top: 2px;
}
.iconGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.iconCell {
display: flex;
align-items: center;
justify-content: center;
height: 34px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background);
color: var(--l2-foreground);
cursor: pointer;
transition:
border-color 0.12s,
color 0.12s;
}
.iconCell:hover {
color: var(--l1-foreground);
border-color: var(--l3-foreground);
}
.iconCellOn {
border-color: var(--primary-background);
color: var(--primary-background);
}
.saveActions {
display: flex;
justify-content: flex-end;

View File

@@ -3,11 +3,11 @@ import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import type { SavedView } from '../../types';
import { type BuiltinView, iconByName } from '../../views';
import { type BuiltinView } from '../../utils/views';
import SaveViewPopover from './SaveViewPopover';
import styles from './ViewsRail.module.scss';
@@ -16,11 +16,12 @@ interface Props {
activeViewId: string;
builtinViews: BuiltinView[];
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
isModified: boolean;
collapsed?: boolean;
onSelect: (id: string) => void;
onSave: (name: string, icon: string) => void;
onSave: (name: string) => void;
onSaveChanges: () => void;
onReset: () => void;
onClearFilters: () => void;
@@ -40,6 +41,7 @@ function ViewsRail({
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
collapsed = false,
@@ -73,11 +75,8 @@ function ViewsRail({
const { destroy } = modal.confirm({
title: (
<Typography.Title level={5}>
Delete the
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
{' '}
{label}{' '}
</span>
Delete the{' '}
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
view?
</Typography.Title>
),
@@ -116,12 +115,10 @@ function ViewsRail({
onClick={(): void => onSelect(row.id)}
testId={`dashboards-view-${row.id}`}
>
<span className={styles.itemIcon}>
<Icon size={14} />
</span>
<span className={styles.itemLabel}>{row.label}</span>
<Icon size={16} className={styles.itemIcon} />
<Typography.Text className={styles.itemLabel}>{row.label}</Typography.Text>
{active && isModified && (
<span className={styles.dirtyDot} title="Unsaved changes" />
<div className={styles.dirtyDot} title="Unsaved changes" />
)}
</Button>
{row.deletable && (
@@ -132,7 +129,10 @@ function ViewsRail({
className={styles.itemAction}
aria-label="Delete view"
title="Delete view"
onClick={(): void => confirmDelete(row.id, row.label)}
onClick={(e): void => {
e.stopPropagation();
confirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
@@ -166,7 +166,7 @@ function ViewsRail({
<div className={styles.search}>
<Input
value={query}
placeholder="Search views"
placeholder="Filter views by name"
prefix={<Search size={12} />}
testId="dashboards-view-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
@@ -196,9 +196,13 @@ function ViewsRail({
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
My views
<span className={styles.groupCount}>{customViews.length}</span>
<Typography.Text className={styles.groupCount}>
{customViews.length}
</Typography.Text>
</div>
{customViews.length === 0 ? (
{customViewsLoading ? (
<div className={styles.empty}>Loading views</div>
) : customViews.length === 0 ? (
<div className={styles.empty}>
No saved views yet. Filter the list, then save it as a view.
</div>
@@ -207,7 +211,7 @@ function ViewsRail({
renderItem({
id: v.id,
label: v.name,
icon: iconByName(v.icon),
icon: Bookmark,
deletable: true,
}),
)

View File

@@ -5,7 +5,7 @@ import { handleContactSupport } from 'container/Integrations/utils';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import { formatQueryErrorMessage } from '../../../utils';
import { formatQueryErrorMessage } from '../../../utils/helpers';
import styles from './ErrorState.module.scss';
interface Props {

View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import type { SelectedTag } from '../types';
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
// The list response only reports the tags present in the current (filtered) page,
// so tags vanish from the filter options as results narrow. Accumulate every tag
// we've ever seen so previously-surfaced tags stay selectable across refetches.
export function useAccumulatedTags(responseTags: SelectedTag[]): SelectedTag[] {
const [tags, setTags] = useState<SelectedTag[]>([]);
useEffect(() => {
if (responseTags.length === 0) {
return;
}
setTags((prev) => {
const merged = new Map(prev.map((t) => [tagId(t), t]));
let changed = false;
responseTags.forEach((t) => {
const id = tagId(t);
if (!merged.has(id)) {
merged.set(id, t);
changed = true;
}
});
return changed ? Array.from(merged.values()) : prev;
});
}, [responseTags]);
return tags;
}

View File

@@ -1,8 +1,17 @@
import { useCallback, useMemo } from 'react';
import { parseAsString, useQueryState, type Options } from 'nuqs';
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DEFAULT_FILTER_STATE, areFilterStatesEqual } from '../filterQuery';
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
import {
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
} from '../utils/filterQuery';
import { BuiltinViewId } from '../types';
import type { DashboardFilterState, SavedView } from '../types';
import {
BUILTIN_VIEWS,
@@ -10,7 +19,8 @@ import {
builtinViewSnapshot,
type BuiltinView,
isClientView,
} from '../views';
} from '../utils/views';
import { useSavedViews } from './useSavedViews';
const opts: Options = { history: 'push' };
@@ -18,43 +28,62 @@ interface UseActiveViewArgs {
filters: DashboardFilterState;
applyFilters: (next: DashboardFilterState) => void;
userEmail: string;
sortColumn: DashboardtypesListSortDTO;
sortOrder: DashboardtypesListOrderDTO;
setSortColumn: (column: DashboardtypesListSortDTO) => void;
setSortOrder: (order: DashboardtypesListOrderDTO) => void;
}
export interface UseActiveViewResult {
activeViewId: string;
builtinViews: BuiltinView[];
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
// Current filters diverge from the active view's canonical snapshot.
isModified: boolean;
// Extra server-query fragment the active view contributes, and whether it
// constrains the list client-side (favorites/recent).
// constrains the list client-side (pinned/recent).
viewQuery: string;
clientView: boolean;
selectView: (id: string) => void;
saveView: (name: string, icon: string) => void;
saveView: (name: string) => void;
saveActiveView: () => void;
resetView: () => void;
removeView: (id: string) => void;
}
// The canonical filter snapshot a saved view "is": the backend stores a flat
// query, so a view folds entirely into the search box with empty chips.
const customSnapshot = (view: SavedView): DashboardFilterState => ({
...DEFAULT_FILTER_STATE,
search: view.query,
});
// Orchestrates the active view: which view is selected (URL `view` param),
// merging built-in + persisted custom views, applying a view's snapshot on
// select, dirty detection, and save/reset/delete.
// merging built-in + org-shared saved views, applying a view's snapshot on
// select, dirty detection, and save/reset/delete via the Views API.
export function useActiveView({
filters,
applyFilters,
userEmail,
sortColumn,
sortOrder,
setSortColumn,
setSortOrder,
}: UseActiveViewArgs): UseActiveViewResult {
const [activeViewId, setActiveViewId] = useQueryState(
'view',
parseAsString.withDefault('all').withOptions(opts),
parseAsString.withDefault(BuiltinViewId.All).withOptions(opts),
);
const customViews = useDashboardViewsStore((s) => s.customViews);
const addView = useDashboardViewsStore((s) => s.addView);
const updateView = useDashboardViewsStore((s) => s.updateView);
const deleteView = useDashboardViewsStore((s) => s.deleteView);
const {
views: customViews,
isLoading: customViewsLoading,
createView,
updateView,
deleteView,
} = useSavedViews();
const activeCustom = useMemo(
() => customViews.find((v) => v.id === activeViewId),
@@ -65,7 +94,7 @@ export function useActiveView({
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
() =>
activeCustom
? activeCustom.filters
? customSnapshot(activeCustom)
: builtinViewSnapshot(activeViewId, userEmail),
[activeCustom, activeViewId, userEmail],
);
@@ -78,47 +107,93 @@ export function useActiveView({
(id: string): void => {
void setActiveViewId(id);
const custom = customViews.find((v) => v.id === id);
applyFilters(
custom?.filters ??
builtinViewSnapshot(id, userEmail) ??
DEFAULT_FILTER_STATE,
);
if (custom) {
applyFilters(customSnapshot(custom));
setSortColumn(custom.sort);
setSortOrder(custom.order);
return;
}
applyFilters(builtinViewSnapshot(id, userEmail) ?? DEFAULT_FILTER_STATE);
},
[setActiveViewId, customViews, applyFilters, userEmail],
[
setActiveViewId,
customViews,
applyFilters,
userEmail,
setSortColumn,
setSortOrder,
],
);
const saveView = useCallback(
(name: string, icon: string): void => {
const id = `cv_${Date.now()}`;
addView({
id,
name: name.trim(),
icon,
filters: { ...filters },
createdAt: Date.now(),
});
void setActiveViewId(id);
(name: string): void => {
// Fold the current built-in clause + chips into a single query string.
const query = combineQueries(
builtinViewQuery(activeViewId),
filterStateToQuery(filters),
);
void (async (): Promise<void> => {
const created = await createView({
name,
query,
sort: sortColumn,
order: sortOrder,
});
if (created) {
void setActiveViewId(created.id);
// Re-apply the folded representation so the new view isn't
// immediately flagged as modified.
applyFilters(customSnapshot(created));
}
})();
},
[addView, filters, setActiveViewId],
[
activeViewId,
filters,
createView,
sortColumn,
sortOrder,
setActiveViewId,
applyFilters,
],
);
const saveActiveView = useCallback((): void => {
if (activeCustom) {
updateView(activeCustom.id, { filters: { ...filters } });
if (!activeCustom) {
return;
}
}, [activeCustom, updateView, filters]);
const query = filterStateToQuery(filters);
updateView(activeCustom.id, {
name: activeCustom.name,
query,
sort: sortColumn,
order: sortOrder,
});
applyFilters({ ...DEFAULT_FILTER_STATE, search: query });
}, [activeCustom, filters, updateView, sortColumn, sortOrder, applyFilters]);
const resetView = useCallback((): void => {
if (canonicalSnapshot) {
applyFilters(canonicalSnapshot);
if (!canonicalSnapshot) {
return;
}
}, [canonicalSnapshot, applyFilters]);
applyFilters(canonicalSnapshot);
if (activeCustom) {
setSortColumn(activeCustom.sort);
setSortOrder(activeCustom.order);
}
}, [
canonicalSnapshot,
applyFilters,
activeCustom,
setSortColumn,
setSortOrder,
]);
const removeView = useCallback(
(id: string): void => {
deleteView(id);
if (activeViewId === id) {
void setActiveViewId('all');
void setActiveViewId(BuiltinViewId.All);
applyFilters(DEFAULT_FILTER_STATE);
}
},
@@ -129,6 +204,7 @@ export function useActiveView({
activeViewId,
builtinViews: BUILTIN_VIEWS,
customViews,
customViewsLoading,
isCustomActive: !!activeCustom,
isModified,
viewQuery: builtinViewQuery(activeViewId),

View File

@@ -11,13 +11,28 @@ import {
DEFAULT_FILTER_STATE,
filterStateToQuery,
isFilterStateEmpty,
} from '../filterQuery';
import type { DashboardFilterState, UpdatedWindow } from '../types';
} from '../utils/filterQuery';
import type {
DashboardFilterState,
SelectedTag,
UpdatedWindow,
} from '../types';
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
const opts: Options = { history: 'push' };
// Tags are carried in the URL as `key:value` strings; split on the first colon.
const parseTag = (raw: string): SelectedTag | null => {
const idx = raw.indexOf(':');
if (idx <= 0) {
return null;
}
return { key: raw.slice(0, idx), value: raw.slice(idx + 1) };
};
const serializeTag = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
export interface UseDashboardFiltersResult {
filters: DashboardFilterState;
// The backend list-filter `query` string derived from the current filters.
@@ -26,6 +41,7 @@ export interface UseDashboardFiltersResult {
setSearch: (value: string) => void;
setCreatedBy: (emails: string[]) => void;
setUpdated: (window: UpdatedWindow) => void;
setTags: (tags: SelectedTag[]) => void;
// Replace the whole filter state at once — used when applying a saved view.
applyFilters: (next: DashboardFilterState) => void;
clearAll: () => void;
@@ -47,10 +63,19 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
'updated',
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
);
const [tagStrings, setTagStringsState] = useQueryState(
'tags',
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
);
const tags = useMemo<SelectedTag[]>(
() => tagStrings.map(parseTag).filter((t): t is SelectedTag => t !== null),
[tagStrings],
);
const filters = useMemo<DashboardFilterState>(
() => ({ search, createdBy, updated }),
[search, createdBy, updated],
() => ({ search, createdBy, updated, tags }),
[search, createdBy, updated, tags],
);
const query = useMemo(() => filterStateToQuery(filters), [filters]);
@@ -76,13 +101,23 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
[setUpdatedState],
);
const setTags = useCallback(
(next: SelectedTag[]): void => {
void setTagStringsState(next.length ? next.map(serializeTag) : null);
},
[setTagStringsState],
);
const applyFilters = useCallback(
(next: DashboardFilterState): void => {
void setSearchState(next.search || null);
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
void setUpdatedState(next.updated);
void setTagStringsState(
next.tags.length ? next.tags.map(serializeTag) : null,
);
},
[setSearchState, setCreatedByState, setUpdatedState],
[setSearchState, setCreatedByState, setUpdatedState, setTagStringsState],
);
const clearAll = useCallback((): void => {
@@ -96,6 +131,7 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
};

View File

@@ -0,0 +1,63 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
usePinDashboardV2,
useUnpinDashboardV2,
} from 'api/generated/services/dashboard';
import { getHttpStatusCode } from 'utils/errorUtils';
const PIN_LIMIT_MESSAGE =
'You can pin up to 10 dashboards. Unpin one to add another.';
export interface UsePinDashboardResult {
// Toggle the pin for a dashboard given its current pinned state.
togglePin: (id: string, pinned: boolean) => void;
isUpdating: boolean;
}
// Wraps the per-user pin/unpin mutations: refreshes the personalized list on
// success and surfaces the 10-pin limit (HTTP 409) as a toast.
export function usePinDashboard(): UsePinDashboardResult {
const queryClient = useQueryClient();
const invalidate = useCallback((): void => {
void invalidateListDashboardsForUserV2(queryClient);
}, [queryClient]);
const pin = usePinDashboardV2({
mutation: {
onSuccess: invalidate,
onError: (error): void => {
toast.error(
getHttpStatusCode(error) === 409
? PIN_LIMIT_MESSAGE
: 'Failed to pin dashboard.',
);
},
},
});
const unpin = useUnpinDashboardV2({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to unpin dashboard.');
},
},
});
const togglePin = useCallback(
(id: string, pinned: boolean): void => {
if (pinned) {
unpin.mutate({ pathParams: { id } });
} else {
pin.mutate({ pathParams: { id } });
}
},
[pin, unpin],
);
return { togglePin, isUpdating: pin.isLoading || unpin.isLoading };
}

View File

@@ -0,0 +1,117 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardViews,
useCreateDashboardView,
useDeleteDashboardView,
useListDashboardViews,
useUpdateDashboardView,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesDashboardViewDTO,
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedView, SavedViewInput } from '../types';
// Schema version stamped on the view's data envelope (the backend requires it).
const VIEW_DATA_VERSION = 'v1';
const toSavedView = (dto: DashboardtypesDashboardViewDTO): SavedView => ({
id: dto.id,
name: dto.name,
query: dto.data.query ?? '',
sort: dto.data.sort ?? DashboardtypesListSortDTO.updated_at,
order: dto.data.order ?? DashboardtypesListOrderDTO.desc,
});
const toPostable = (
input: SavedViewInput,
): { name: string; data: DashboardtypesDashboardViewDTO['data'] } => ({
name: input.name.trim(),
data: {
version: VIEW_DATA_VERSION,
query: input.query,
sort: input.sort,
order: input.order,
},
});
export interface UseSavedViewsResult {
views: SavedView[];
isLoading: boolean;
createView: (input: SavedViewInput) => Promise<SavedView | null>;
updateView: (id: string, input: SavedViewInput) => void;
deleteView: (id: string) => void;
}
// Org-shared saved views, backed by the Views API. Exposes the list plus
// create/update/delete that invalidate the list on success.
export function useSavedViews(): UseSavedViewsResult {
const queryClient = useQueryClient();
const { data, isLoading } = useListDashboardViews();
const views = useMemo<SavedView[]>(
() => (data?.data?.views ?? []).map(toSavedView),
[data],
);
const invalidate = useCallback((): void => {
void invalidateListDashboardViews(queryClient);
}, [queryClient]);
const createMutation = useCreateDashboardView({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to save view.');
},
},
});
const updateMutation = useUpdateDashboardView({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to update view.');
},
},
});
const deleteMutation = useDeleteDashboardView({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to delete view.');
},
},
});
const createView = useCallback(
async (input: SavedViewInput): Promise<SavedView | null> => {
try {
const res = await createMutation.mutateAsync({ data: toPostable(input) });
return res?.data ? toSavedView(res.data) : null;
} catch {
return null;
}
},
[createMutation],
);
const updateView = useCallback(
(id: string, input: SavedViewInput): void => {
updateMutation.mutate({ pathParams: { id }, data: toPostable(input) });
},
[updateMutation],
);
const deleteView = useCallback(
(id: string): void => {
deleteMutation.mutate({ pathParams: { id } });
},
[deleteMutation],
);
return { views, isLoading, createView, updateView, deleteView };
}

View File

@@ -2,30 +2,21 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
import type { SavedView } from '../types';
// Most-recently-viewed list is capped so it stays a useful shortlist.
const RECENT_LIMIT = 20;
// Client-side persistence for everything the views feature owns until the views
// API lands: user-saved views, favorite/recently-viewed dashboard ids, and the
// rail collapse preference. Mirrors `useDashboardsListVisibleColumnsStore`.
// Client-side persistence for the parts of the views feature that aren't backed
// by an API: recently-viewed dashboard ids and the rail collapse preference.
// (Saved views are org-shared via the Views API — see `useSavedViews`; pinning
// is server-side per-user — see `usePinDashboard`.)
interface DashboardViewsState {
customViews: SavedView[];
favorites: string[]; // dashboard ids
recent: string[]; // dashboard ids, most-recent first
railCollapsed: boolean;
addView: (view: SavedView) => void;
updateView: (id: string, patch: Partial<Omit<SavedView, 'id'>>) => void;
deleteView: (id: string) => void;
toggleFavorite: (id: string) => void;
markViewed: (id: string) => void;
setRailCollapsed: (collapsed: boolean) => void;
}
const DEFAULT_STATE = {
customViews: [] as SavedView[],
favorites: [] as string[],
recent: [] as string[],
railCollapsed: false,
};
@@ -34,26 +25,6 @@ export const useDashboardViewsStore = create<DashboardViewsState>()(
persist(
(set) => ({
...DEFAULT_STATE,
addView: (view): void => {
set((s) => ({ customViews: [...s.customViews, view] }));
},
updateView: (id, patch): void => {
set((s) => ({
customViews: s.customViews.map((v) =>
v.id === id ? { ...v, ...patch } : v,
),
}));
},
deleteView: (id): void => {
set((s) => ({ customViews: s.customViews.filter((v) => v.id !== id) }));
},
toggleFavorite: (id): void => {
set((s) => ({
favorites: s.favorites.includes(id)
? s.favorites.filter((f) => f !== id)
: [...s.favorites, id],
}));
},
markViewed: (id): void => {
set((s) => ({
recent: [id, ...s.recent.filter((r) => r !== id)].slice(0, RECENT_LIMIT),

View File

@@ -1,27 +1,52 @@
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
// Relative "updated within" windows offered by the Updated filter chip.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
// The user-controllable filter state a view captures. (Tags are intentionally
// excluded for now — the tag filter UI is deferred.) Sort/order are handled
// separately via URL query params and are not part of a view snapshot.
// A tag selected in the Tags filter chip — a concrete key:value pair drawn from
// the tags the list API reports across the org's dashboards.
export interface SelectedTag {
key: string;
value: string;
}
// The user-controllable filter state a view captures. `search` is a raw filter
// DSL fragment the user types; the structured chips (created-by, updated, tags)
// are AND-ed onto it. Sort/order are handled separately via URL query params and
// are not part of a view snapshot.
export interface DashboardFilterState {
search: string;
createdBy: string[]; // emails (created_by)
updated: UpdatedWindow;
tags: SelectedTag[];
}
// A saved view: a named, iconed snapshot of filter state. Persisted client-side
// (localStorage) until the views API lands.
// A saved view: a named filter the org shares, persisted via the backend Views
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
// so a view captures the fully-combined DSL query plus the sort/order to apply.
export interface SavedView {
id: string;
name: string;
icon: string; // @signozhq/icons icon name
filters: DashboardFilterState;
createdAt: number;
query: string;
sort: DashboardtypesListSortDTO;
order: DashboardtypesListOrderDTO;
}
// The payload for creating or updating a saved view (everything but the id).
export type SavedViewInput = Omit<SavedView, 'id'>;
// Built-in views rendered above the user's saved views. Their result set is
// derived (a fixed query fragment or a client-side id set), never persisted.
export type BuiltinViewId = 'mine' | 'favorites' | 'recent' | 'all' | 'locked';
// String values double as the URL `view` param, so they must stay stable.
export enum BuiltinViewId {
Mine = 'mine',
Pinned = 'pinned',
Recent = 'recent',
All = 'all',
Locked = 'locked',
}
export type ViewSection = 'personal' | 'system' | 'custom';

View File

@@ -0,0 +1,57 @@
import {
applyKeySuggestion,
buildSuggestionKeys,
getActiveKeyToken,
matchKeys,
RESERVED_DSL_KEYS,
} from './dslSuggestions';
describe('getActiveKeyToken', () => {
it('returns the partial key at the start', () => {
expect(getActiveKeyToken('nam')).toStrictEqual({ token: 'nam', start: 0 });
});
it('returns the partial key after AND', () => {
const value = 'name = "x" AND en';
expect(getActiveKeyToken(value)).toStrictEqual({ token: 'en', start: 15 });
});
it('is null once an operator (space) has been typed', () => {
expect(getActiveKeyToken('name contains')).toBeNull();
});
it('is null for an empty trailing segment', () => {
expect(getActiveKeyToken('name = "x" AND ')).toBeNull();
});
});
describe('buildSuggestionKeys', () => {
it('lists reserved keys plus distinct tag keys', () => {
const keys = buildSuggestionKeys([
{ key: 'env', value: 'prod' },
{ key: 'env', value: 'dev' },
{ key: 'team', value: 'core' },
]);
expect(keys).toStrictEqual([...RESERVED_DSL_KEYS, 'env', 'team']);
});
});
describe('matchKeys', () => {
it('matches case-insensitively and excludes exact matches', () => {
expect(matchKeys(['name', 'created_by', 'env'], 'NAM')).toStrictEqual([
'name',
]);
expect(matchKeys(['name'], 'name')).toStrictEqual([]);
});
});
describe('applyKeySuggestion', () => {
it('replaces the partial key with the chosen key and a trailing space', () => {
const value = 'name = "x" AND en';
const active = getActiveKeyToken(value);
if (!active) {
throw new Error('expected an active key token');
}
expect(applyKeySuggestion(value, active, 'env')).toBe('name = "x" AND env ');
});
});

View File

@@ -0,0 +1,68 @@
// Key-name suggestions for the dashboards-list DSL search box. The reserved keys
// mirror the backend filter DSL (pkg/.../listfilter_visitor.go); any other key is
// treated as a tag key, so we also surface the tag keys the list API reports.
import type { SelectedTag } from '../types';
// Reserved DSL keys the backend recognises as dashboard columns.
export const RESERVED_DSL_KEYS: string[] = [
'name',
'description',
'created_at',
'updated_at',
'created_by',
'locked',
'source',
];
export interface ActiveKeyToken {
token: string;
// Index in the value string where the partial key begins.
start: number;
}
// The partial key the user is currently typing: the trailing segment after the
// last top-level AND/OR (or the start), provided it hasn't yet reached an
// operator (no whitespace). Returns null once the key is complete.
export const getActiveKeyToken = (value: string): ActiveKeyToken | null => {
const boundaryRe = /\b(?:AND|OR)\b/gi;
let lastEnd = 0;
let match = boundaryRe.exec(value);
while (match !== null) {
lastEnd = match.index + match[0].length;
match = boundaryRe.exec(value);
}
const segment = value.slice(lastEnd);
const leading = segment.length - segment.trimStart().length;
const partial = segment.slice(leading);
if (partial.length === 0 || /[\s(]/.test(partial)) {
return null;
}
return { token: partial, start: lastEnd + leading };
};
// Build the de-duplicated, ordered list of keys to offer: reserved columns plus
// distinct tag keys from the list response.
export const buildSuggestionKeys = (availableTags: SelectedTag[]): string[] => {
const tagKeys = availableTags.map((t) => t.key);
return Array.from(new Set([...RESERVED_DSL_KEYS, ...tagKeys]));
};
// Keys matching the partial token (case-insensitive), excluding an exact match.
export const matchKeys = (
keys: string[],
token: string,
limit = 8,
): string[] => {
const lower = token.toLowerCase();
return keys
.filter((k) => k.toLowerCase().includes(lower) && k.toLowerCase() !== lower)
.slice(0, limit);
};
// Replace the active partial key in `value` with the chosen key + a space, ready
// for the user to type an operator.
export const applyKeySuggestion = (
value: string,
active: ActiveKeyToken,
key: string,
): string => `${value.slice(0, active.start)}${key} `;

View File

@@ -0,0 +1,109 @@
import {
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
isFilterStateEmpty,
} from './filterQuery';
import type { DashboardFilterState } from '../types';
const state = (patch: Partial<DashboardFilterState>): DashboardFilterState => ({
...DEFAULT_FILTER_STATE,
...patch,
});
describe('filterStateToQuery', () => {
it('passes the raw search through, wrapped in parentheses', () => {
expect(filterStateToQuery(state({ search: 'name contains "prod"' }))).toBe(
'(name contains "prod")',
);
});
it('emits an equality clause for a single creator', () => {
expect(filterStateToQuery(state({ createdBy: ['a@b.com'] }))).toBe(
"created_by = 'a@b.com'",
);
});
it('emits an IN clause for multiple creators', () => {
expect(filterStateToQuery(state({ createdBy: ['a@b.com', 'c@d.com'] }))).toBe(
"created_by IN ['a@b.com', 'c@d.com']",
);
});
it('emits an exact equality clause per selected tag', () => {
expect(
filterStateToQuery(
state({
tags: [
{ key: 'env', value: 'prod' },
{ key: 'team', value: 'core' },
],
}),
),
).toBe("env = 'prod' AND team = 'core'");
});
it('ANDs raw search with the structured chips', () => {
expect(
filterStateToQuery(
state({
search: 'name contains "x"',
createdBy: ['a@b.com'],
tags: [{ key: 'env', value: 'prod' }],
}),
),
).toBe("(name contains \"x\") AND created_by = 'a@b.com' AND env = 'prod'");
});
it('returns an empty string for the default state', () => {
expect(filterStateToQuery(DEFAULT_FILTER_STATE)).toBe('');
});
});
describe('isFilterStateEmpty', () => {
it('is true for the default state', () => {
expect(isFilterStateEmpty(DEFAULT_FILTER_STATE)).toBe(true);
});
it('is false when any tag is selected', () => {
expect(
isFilterStateEmpty(state({ tags: [{ key: 'env', value: 'prod' }] })),
).toBe(false);
});
});
describe('areFilterStatesEqual', () => {
it('ignores tag ordering', () => {
const a = state({
tags: [
{ key: 'env', value: 'prod' },
{ key: 'team', value: 'core' },
],
});
const b = state({
tags: [
{ key: 'team', value: 'core' },
{ key: 'env', value: 'prod' },
],
});
expect(areFilterStatesEqual(a, b)).toBe(true);
});
it('distinguishes differing tag selections', () => {
expect(
areFilterStatesEqual(
state({ tags: [{ key: 'env', value: 'prod' }] }),
state({ tags: [{ key: 'env', value: 'dev' }] }),
),
).toBe(false);
});
});
describe('combineQueries', () => {
it('drops empty fragments and ANDs the rest', () => {
expect(combineQueries('locked = true', '', undefined, 'name = "x"')).toBe(
'locked = true AND name = "x"',
);
});
});

View File

@@ -1,11 +1,12 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type {
DashboardtypesListedDashboardV2DTO,
DashboardtypesListedDashboardForUserV2DTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
// The list is fetched via the per-user endpoint, so every row carries `pinned`.
export type DashboardListItem = DashboardtypesListedDashboardForUserV2DTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,
@@ -14,23 +15,23 @@ export const tagsToStrings = (
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
);
// Inverse of `tagsToStrings`: each comma-separated tag is "key:value" or a bare
// label (key === value).
export const toPostableTags = (raw: string): TagtypesPostableTagDTO[] =>
raw
.split(',')
.map((label) => label.trim())
.filter(Boolean)
.map((label) => {
const sep = label.indexOf(':');
if (sep > 0) {
return {
key: label.slice(0, sep).trim(),
value: label.slice(sep + 1).trim(),
};
// Convert validated `key:value` tag strings (from TagKeyValueInput) into the
// postable tag DTO shape. The first colon separates key from value.
export const keyValueStringsToTags = (
tags: string[],
): TagtypesPostableTagDTO[] =>
tags
.map((tag) => {
const idx = tag.indexOf(':');
if (idx <= 0) {
return null;
}
return { key: label, value: label };
});
return {
key: tag.slice(0, idx).trim(),
value: tag.slice(idx + 1).trim(),
};
})
.filter((t): t is TagtypesPostableTagDTO => !!t?.key && !!t.value);
export const lastUpdatedLabel = (time: string | undefined): string => {
if (!time || isEmpty(time)) {

View File

@@ -3,26 +3,15 @@
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
// - query: contributes an extra server clause AND-ed with the chips (Locked)
// - client: constrains by a client-side id set (Favorites, Recently viewed)
import {
Activity,
Bookmark,
Clock,
Code,
Flag,
Layers,
Lock,
Server,
Star,
Tag,
User,
} from '@signozhq/icons';
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
import { DEFAULT_FILTER_STATE } from './filterQuery';
import type { BuiltinViewId, DashboardFilterState, ViewSection } from './types';
import type { DashboardListItem } from './utils';
import { BuiltinViewId } from '../types';
import type { DashboardFilterState, ViewSection } from '../types';
import type { DashboardListItem } from './helpers';
// All @signozhq icons share this component type.
export type ViewIcon = typeof Star;
export type ViewIcon = typeof Pin;
export interface BuiltinView {
id: BuiltinViewId;
@@ -32,40 +21,37 @@ export interface BuiltinView {
}
export const BUILTIN_VIEWS: BuiltinView[] = [
{ id: 'mine', label: 'My dashboards', icon: User, section: 'personal' },
{ id: 'favorites', label: 'Favorites', icon: Star, section: 'personal' },
{ id: 'recent', label: 'Recently viewed', icon: Clock, section: 'personal' },
{ id: 'all', label: 'All dashboards', icon: Layers, section: 'system' },
{ id: 'locked', label: 'Locked', icon: Lock, section: 'system' },
{
id: BuiltinViewId.Mine,
label: 'My dashboards',
icon: User,
section: 'personal',
},
{ id: BuiltinViewId.Pinned, label: 'Pinned', icon: Pin, section: 'personal' },
{
id: BuiltinViewId.Recent,
label: 'Recently viewed',
icon: Clock,
section: 'personal',
},
{
id: BuiltinViewId.All,
label: 'All dashboards',
icon: Layers,
section: 'system',
},
{ id: BuiltinViewId.Locked, label: 'Locked', icon: Lock, section: 'system' },
];
// Icons offered when naming a saved view; stored by name on the view.
export const VIEW_ICON_OPTIONS: { name: string; Icon: ViewIcon }[] = [
{ name: 'bookmark', Icon: Bookmark },
{ name: 'star', Icon: Star },
{ name: 'layers', Icon: Layers },
{ name: 'activity', Icon: Activity },
{ name: 'server', Icon: Server },
{ name: 'code', Icon: Code },
{ name: 'flag', Icon: Flag },
{ name: 'tag', Icon: Tag },
{ name: 'lock', Icon: Lock },
{ name: 'clock', Icon: Clock },
];
const ICON_BY_NAME = new Map(VIEW_ICON_OPTIONS.map((o) => [o.name, o.Icon]));
export const iconByName = (name: string): ViewIcon =>
ICON_BY_NAME.get(name) ?? Bookmark;
// Favorites/Recently-viewed constrain by a client-side id set — the backend has
// no id filter, so these are filtered on the fetched rows.
// Pinned/Recently-viewed constrain client-side — Pinned by the per-row `pinned`
// flag, Recently-viewed by a localStorage id set — so they filter the fetched
// rows rather than adding a server clause.
export const isClientView = (id: string): boolean =>
id === 'favorites' || id === 'recent';
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
// Extra server query fragment a built-in view contributes (AND-ed with chips).
export const builtinViewQuery = (id: string): string =>
id === 'locked' ? 'locked = true' : '';
id === BuiltinViewId.Locked ? 'locked = true' : '';
// The canonical filter snapshot a built-in view applies when selected. `null`
// for ids that aren't built-in (custom views carry their own snapshot).
@@ -74,15 +60,15 @@ export const builtinViewSnapshot = (
userEmail: string,
): DashboardFilterState | null => {
switch (id) {
case 'mine':
case BuiltinViewId.Mine:
return {
...DEFAULT_FILTER_STATE,
createdBy: userEmail ? [userEmail] : [],
};
case 'all':
case 'favorites':
case 'recent':
case 'locked':
case BuiltinViewId.All:
case BuiltinViewId.Pinned:
case BuiltinViewId.Recent:
case BuiltinViewId.Locked:
return { ...DEFAULT_FILTER_STATE };
default:
return null;
@@ -109,22 +95,22 @@ export const noResultsCopy = (
};
}
switch (activeViewId) {
case 'favorites':
case BuiltinViewId.Pinned:
return {
title: 'No favorite dashboards yet',
description: 'Star a dashboard to pin it here.',
title: 'No pinned dashboards yet',
description: 'Pin a dashboard to keep it handy here.',
};
case 'recent':
case BuiltinViewId.Recent:
return {
title: 'No recently viewed dashboards',
description: 'Dashboards you open will appear here.',
};
case 'locked':
case BuiltinViewId.Locked:
return {
title: 'No locked dashboards',
description: 'Dashboards locked for editing will appear here.',
};
case 'mine':
case BuiltinViewId.Mine:
return {
title: "You haven't created any dashboards",
description: 'Dashboards you create will appear here.',
@@ -142,19 +128,18 @@ export const noResultsCopy = (
}
};
// Apply a client-side view's id-set constraint to already-fetched rows.
// Recently-viewed preserves visit order regardless of the active sort.
// Apply a client-side view's constraint to already-fetched rows. Pinned filters
// by the per-row `pinned` flag; Recently-viewed filters by a localStorage id set
// and preserves visit order regardless of the active sort.
export const applyClientView = (
items: DashboardListItem[],
id: string,
favorites: string[],
recent: string[],
): DashboardListItem[] => {
if (id === 'favorites') {
const set = new Set(favorites);
return items.filter((d) => set.has(d.id));
if (id === BuiltinViewId.Pinned) {
return items.filter((d) => d.pinned);
}
if (id === 'recent') {
if (id === BuiltinViewId.Recent) {
const order = new Map(recent.map((rid, index) => [rid, index]));
return items
.filter((d) => order.has(d.id))

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

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -406,27 +405,34 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
isRawRequest := dashboard.getQueryRequestTypeFromPanelType(widgetData.PanelTypes) == querybuildertypesv5.RequestTypeRaw
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
// build aggregations the same way the frontend does before hitting the query
// range API; raw requests carry no aggregations.
if isRawRequest {
delete(query, "aggregations")
} else {
query["aggregations"] = querybuildertypesv5.CreateAggregation(query, widgetData.PanelTypes)
}
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -0,0 +1,214 @@
package querybuildertypesv5
import (
"regexp"
"strings"
)
// WrapInV5Envelope translates a single v4 builder query/formula map into a
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
// from the data source. queryType selects the envelope type, except a formula
// (detected when name != queryMap["expression"]) is always emitted as
// "builder_formula".
//
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
// produce v5 envelopes, so this lives here with the v5 query types rather than
// in an infra-level package.
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// aggregationExprRegexp matches a function-style aggregation like `count()` or
// `sum(field)` with an optional `as <alias>`, as the frontend's parseAggregations does.
var aggregationExprRegexp = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+((?:'[^']*'|"[^"]*"|[a-zA-Z0-9_-]+)))?`)
// CreateAggregation builds the v5 aggregations for a stored builder query, mirroring
// createAggregation in the frontend's prepareQueryRangePayloadV5.ts. Metrics yield a
// single structured aggregation; logs/traces split their comma-separated expression into
// one aggregation per call, defaulting to count() when nothing parses.
func CreateAggregation(queryData map[string]any, panelType string) []any {
if queryData == nil {
return []any{}
}
if dataSource, _ := queryData["dataSource"].(string); dataSource == "metrics" {
var first map[string]any
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
first, _ = aggs[0].(map[string]any)
}
attribute, _ := queryData["aggregateAttribute"].(map[string]any)
metric := map[string]any{}
setFirstNonEmpty(metric, "metricName", first["metricName"], attribute["key"])
setFirstNonEmpty(metric, "temporality", first["temporality"], attribute["temporality"])
setFirstNonEmpty(metric, "timeAggregation", first["timeAggregation"], queryData["timeAggregation"])
setFirstNonEmpty(metric, "spaceAggregation", first["spaceAggregation"], queryData["spaceAggregation"])
if panelType == "table" || panelType == "pie" || panelType == "value" {
setFirstNonEmpty(metric, "reduceTo", first["reduceTo"], queryData["reduceTo"])
}
return []any{metric}
}
aggs, ok := queryData["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return []any{map[string]any{"expression": "count()"}}
}
result := []any{}
for _, agg := range aggs {
aggMap, _ := agg.(map[string]any)
expression, _ := aggMap["expression"].(string)
alias, _ := aggMap["alias"].(string)
parsed := parseAggregations(expression, alias)
if len(parsed) == 0 {
result = append(result, map[string]any{"expression": "count()"})
continue
}
result = append(result, parsed...)
}
return result
}
// parseAggregations extracts each function-style call from a (possibly comma-separated)
// aggregation expression, attaching the inline `as` alias or the fallback alias.
func parseAggregations(expression, fallbackAlias string) []any {
result := []any{}
for _, match := range aggregationExprRegexp.FindAllStringSubmatch(expression, -1) {
agg := map[string]any{"expression": match[1]}
if alias := match[2]; alias != "" {
agg["alias"] = strings.Trim(alias, `'"`)
} else if fallbackAlias != "" {
agg["alias"] = fallbackAlias
}
result = append(result, agg)
}
return result
}
// setFirstNonEmpty sets key to the first value that is neither nil nor "", mirroring the
// JS `a || b` fallback the frontend uses for the metric aggregation fields.
func setFirstNonEmpty(target map[string]any, key string, values ...any) {
for _, v := range values {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
target[key] = v
return
}
}

View File

@@ -0,0 +1,176 @@
package querybuildertypesv5
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateAggregation(t *testing.T) {
testCases := []struct {
description string
queryData map[string]any
panelType string
expectedOutput []any
}{
{
description: "nil query data yields no aggregations",
queryData: nil,
expectedOutput: []any{},
},
{
description: "single logs expression is left untouched",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "comma separated trace expressions are split into one object each",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "count(), sum(price)"}}},
expectedOutput: []any{
map[string]any{"expression": "count()"},
map[string]any{"expression": "sum(price)"},
},
},
{
description: "inline alias is preserved and unquoted",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as 'total', sum(price) as revenue"}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "total"},
map[string]any{"expression": "sum(price)", "alias": "revenue"},
},
},
{
description: "space separated expressions split with an unquoted alias on the first only",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as cnt avg(code.lineno) "}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "cnt"},
map[string]any{"expression": "avg(code.lineno)"},
},
},
{
description: "fallback alias is applied when expression has no inline alias",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()", "alias": "hits"}}},
expectedOutput: []any{map[string]any{"expression": "count()", "alias": "hits"}},
},
{
description: "commas inside function arguments do not split the expression",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "countIf(day > 10, status)"}}},
expectedOutput: []any{map[string]any{"expression": "countIf(day > 10, status)"}},
},
{
description: "unparseable expression falls back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "not-an-aggregation"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "empty aggregations fall back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "missing aggregations fall back to count()",
queryData: map[string]any{"dataSource": "traces"},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "metric aggregation is built from the first aggregation",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric omits temporality when empty, matching the frontend `|| undefined`",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"temporality": "",
"reduceTo": "avg",
"aggregations": []any{map[string]any{
"metricName": "cpu_usage",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "cpu_usage",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
{
description: "metric includes reduceTo for table/pie/value panels",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
{
description: "metric drops reduceTo for other panels even when query data has it",
panelType: "graph",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric falls back to legacy aggregateAttribute and top-level fields",
queryData: map[string]any{
"dataSource": "metrics",
"aggregateAttribute": map[string]any{"key": "legacy_metric", "temporality": "cumulative"},
"timeAggregation": "avg",
"spaceAggregation": "max",
},
expectedOutput: []any{map[string]any{
"metricName": "legacy_metric",
"temporality": "cumulative",
"timeAggregation": "avg",
"spaceAggregation": "max",
}},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(t, testCase.expectedOutput, CreateAggregation(testCase.queryData, testCase.panelType))
})
}
}

View File

@@ -0,0 +1,10 @@
package querybuildertypesv5
// PreviewTask is one rendered statement queued for granule/estimate analysis.
// StmtIdx is where its results merge back into the query's Statements.
type PreviewTask struct {
Name string
StmtIdx int
Query string
Args []any
}

View File

@@ -58,3 +58,8 @@ type TraceOperatorStatementBuilder interface {
// Build builds the trace operator query.
Build(ctx context.Context, start, end uint64, requestType RequestType, query QueryBuilderTraceOperator, compositeQuery *CompositeQuery) (*Statement, error)
}
// StatementProvider renders a query's underlying statement without executing it.
type StatementProvider interface {
Statement(ctx context.Context) (*Statement, error)
}

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
@@ -64,6 +66,64 @@ type QueryRangeResponse struct {
QBEvent *QBEvent `json:"-"`
}
// QueryRangePreviewResponse is the dry-run output: one QueryPreview per query,
// keyed by the request's query names.
type QueryRangePreviewResponse struct {
CompositeQuery map[string]QueryPreview `json:"compositeQuery" required:"true" nullable:"true"`
}
// QueryRangePreviewOptions carries per-call options for the dry-run endpoint.
type QueryRangePreviewOptions struct {
Verbose bool
}
// QueryRangePreviewParams are the query-string parameters of the dry-run endpoint.
type QueryRangePreviewParams struct {
Verbose string `query:"verbose"`
}
// PrepareJSONSchema adds description to the QueryRangePreviewResponse schema.
func (q *QueryRangePreviewResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("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.")
return nil
}
// QueryPreview is the dry-run result for a single query.
type QueryPreview struct {
Valid bool `json:"valid" required:"true" nullable:"false"`
Error error `json:"error" required:"true"`
Warnings []string `json:"warnings" required:"true" nullable:"false"`
Statements []PreviewStatement `json:"statements" required:"true" nullable:"false"`
}
// PreviewStatement is one rendered ClickHouse statement with its args and, when
// requested, its EXPLAIN ESTIMATE and granule breakdown. The query/args JSON
// keys follow the OpenTelemetry db.statement.* convention.
type PreviewStatement struct {
Query string `json:"db.statement.query" required:"true" nullable:"false"`
Args []any `json:"db.statement.args" required:"true" nullable:"false"`
Estimate []telemetrystoretypes.EstimateEntry `json:"estimate" required:"true" nullable:"false"`
Granules *telemetrystoretypes.Granules `json:"granules" required:"true" nullable:"true"`
}
// MarshalJSON renders Error in its structured form (code/message/suggestions)
// rather than the empty object a bare error produces. The nullable:"false"
// arrays are non-nil from the producer, so they marshal as [] rather than null.
func (p QueryPreview) MarshalJSON() ([]byte, error) {
type alias QueryPreview
out := struct {
alias
Error *errors.JSON `json:"error"`
}{alias: alias(p)}
out.alias.Error = nil
// Derive the verdict so the two can't desync.
out.Valid = p.Error == nil
if p.Error != nil {
out.Error = errors.AsJSON(p.Error)
}
return json.Marshal(out)
}
var _ jsonschema.Preparer = &QueryRangeResponse{}
// PrepareJSONSchema adds description to the QueryRangeResponse schema.
@@ -256,7 +316,6 @@ type RawStream struct {
Error chan error
}
func roundToNonZeroDecimals(val float64, n int) float64 {
if val == 0 || math.IsNaN(val) || math.IsInf(val, 0) {
return val

View File

@@ -575,6 +575,75 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
return nil
}
// ValidateRequestScope validates request-level invariants (not individual query
// specs) and returns the request type's ValidationOptions. The dry-run path uses
// this so per-query errors can be attributed individually via QueryEnvelope.Validate
// instead of failing fast like Validate does.
func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
}
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
opts = GetValidationOptions(r.RequestType)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar")
}
if r.RequestType == RequestTypeRaw || r.RequestType == RequestTypeRawStream || r.RequestType == RequestTypeTrace {
for _, envelope := range r.CompositeQuery.Queries {
if envelope.GetSignal() == telemetrytypes.SignalMetrics {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "raw request type is not supported for metric queries")
}
}
}
if len(r.CompositeQuery.Queries) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "at least one query is required")
}
// Builder query names must be unique across the composite query.
queryNames := make(map[string]bool)
for _, envelope := range r.CompositeQuery.Queries {
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
name := envelope.GetQueryName()
if name != "" {
if queryNames[name] {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "duplicate query name '%s'", name)
}
queryNames[name] = true
}
}
}
if err := r.validateAllQueriesNotDisabled(); err != nil {
return nil, err
}
return opts, nil
}
// Validate parses the preview query-string parameters. Verbose defaults to true
// and accepts true/1/false/0; any other value is rejected.
func (p *QueryRangePreviewParams) Validate() (QueryRangePreviewOptions, error) {
switch strings.ToLower(strings.TrimSpace(p.Verbose)) {
case "", "true", "1":
return QueryRangePreviewOptions{Verbose: true}, nil
case "false", "0":
return QueryRangePreviewOptions{Verbose: false}, nil
}
return QueryRangePreviewOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid verbose value %q (allowed: true, false)", p.Verbose)
}
// Validate validates a single query envelope's spec — the per-query counterpart
// to ValidateRequestScope, letting the dry-run report errors independently.
func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
return validateQueryEnvelope(e, opts...)
}
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled.
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
for _, envelope := range r.CompositeQuery.Queries {

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