Compare commits

..

6 Commits

Author SHA1 Message Date
Vikrant Gupta
19712c3579 feat(authdomain): support custom roles in SSO group mapping (#11858)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(authdomain): support custom roles in SSO group mapping

Role mappings now reference SigNoz roles by name (custom or managed)
instead of the legacy ADMIN/EDITOR/VIEWER enum. Legacy values sent by a
client are normalized to their managed names (signoz-admin/editor/viewer),
and a migration normalizes existing stored mappings.

- normalize legacy role names on unmarshal; resolve all matched roles on
  SSO callback (union of matched groups, else default, else viewer)
- validate referenced roles exist on auth domain create/update
- block deleting a role referenced by any auth domain mapping, naming the
  referencing domains (OnBeforeRoleDelete now also passes the role name)
- migration 096 to rewrite legacy names in existing auth_domain mappings

* refactor(sqlmigration): decode SSO role mapping into a typed struct

Decode the roleMapping object into a small frozen struct instead of
poking at json.RawMessage per field. The top-level config stays a raw
map so the other config fields are preserved untouched.

* fix(authdomain): validate SSO role attribute claim against existing roles

When the role mapping uses the IDP role attribute, the claimed role is
assigned only if it exists in the org (resolved case-insensitively, with
legacy ADMIN/EDITOR/VIEWER mapped to their managed names); otherwise the
mapping falls through to group mappings and the default role. This lets
custom roles be assigned via the attribute and avoids failing login on an
unknown claim.

Also normalize legacy role names case-insensitively and fold the
single-use managed-role lookup into NormalizeRoleName.
2026-06-26 14:26:43 +00:00
ElyesLaaribi
e31683be11 fix(metrics-explorer): resolve pagination overlap in table (#11812)
* fix: resolve pagination overlap in metrics explorer table

* fix: add padding-bottom to metrics table container per review feedback
2026-06-26 13:59:04 +00:00
Tushar Vats
1700ad06e6 chore: refactor error response + normalize ClickHouse telemetrystore errors (#11740)
* chore: mark required and nullable in json tag, renamed methods, and added more functionality

* fix: unit test

* fix: cast clickhouse exceptions

* fix: go mod tidy

* fix: telemetrystore now returns explicit base errors

* fix: typo

* fix: added all changes

* fix: added nil check

* fix: update test files

* fix: addressed comments

* fix: change errors and suggestions to be non-nullable
2026-06-26 13:44:38 +00:00
Vikrant Gupta
ee3b45b80d feat(statsreporter): track role counts (#11866)
Register the authz provider as a stats collector and emit per-org
role counts (role.count, role.custom.count, role.managed.count) using
the same dynamic key pattern as authdomain.
2026-06-26 13:24:59 +00:00
Vinicius Lourenço
4771e30c03 fix(keyboard-shortcut): monaco-editor should not trigger shortcut (#11848) 2026-06-26 12:22:03 +00:00
Pandey
e933fa74c7 feat(querybuilder): type untyped value fields in v5 openapi schema (#11850)
* feat(querybuilder): type untyped value fields in v5 openapi schema

The query-builder v5 types FunctionArg, VariableItem and Label carry a
`Value any` field that the reflector rendered as an untyped `{}` in the
OpenAPI spec, and QueryEnvelope left an untyped `spec: {}` on its oneOf
base. Add PrepareJSONSchema methods that document the real wire contract:

- FunctionArg.value  -> oneOf [number, string]
- VariableItem.value -> oneOf [string, number, boolean, array<scalar>]
- Label.value        -> oneOf [string, number, boolean]
- QueryEnvelope      -> drop the duplicate base properties so only the
                        typed oneOf-of-$ref variants remain

The Go fields stay `any`; this only shapes the generated schema, so the
runtime decode paths and the existing wire format are unchanged.

* chore(api): regenerate openapi spec and frontend client

Regenerates docs/api/openapi.yml (`generate openapi`) and the orval
frontend client (`generate:api`) for the v5 schema typing. The four
value/spec fields are now typed instead of untyped `{}`, and the
QueryEnvelope DTO collapses from a union of `& { spec?: unknown }`
intersections into a clean discriminated union.

* feat(querybuilder): expose builder_join variant and nullable variable value

- Uncomment the QueryEnvelope builder_join variant so the discriminated
  union includes joins (the runtime UnmarshalJSON already decodes them),
  and type QueryBuilderJoin.aggregations (a []any holding trace/log/metric
  aggregations) as a oneOf of the concrete aggregation schemas instead of
  an untyped {}.
- Mark VariableItem.value nullable: the frontend sends null for a dynamic
  variable whose "ALL" option is selected.

Go field types are unchanged; this only shapes the generated schema.

* chore(api): regenerate spec and frontend client for join + nullable value

Regenerates docs/api/openapi.yml and the orval frontend client. The
QueryEnvelope union gains the builder_join variant (with typed
aggregations), and VariableItem.value is now string | number | boolean |
array | null.

* refactor(querybuilder): extract join aggregations into a named JoinAggregation

The previous inline `items.oneOf` on QueryBuilderJoin.aggregations isn't
mappable by code generators — tfplugingen-openapi rejects a oneOf buried
inline in array items ("schema composition is currently not supported"),
and skaff's detectOneOfRewrites only rewrites top-level component schemas.

Introduce a named JoinAggregation element type exposing the trace/log/metric
shapes via JSONSchemaOneOf, so the schema becomes a named oneOf-of-$ref
component (which generators flatten into one object with three optional
sub-objects) instead of an inline union. The runtime value stays opaque and
the wire shape is unchanged via transparent Marshal/UnmarshalJSON.

* chore(api): regenerate spec and frontend client for JoinAggregation

QueryBuilderJoin.aggregations items now reference the named
Querybuildertypesv5JoinAggregation oneOf component.

* feat(querybuilder): add an OpenAPI discriminator to the QueryEnvelope union

Collapse the three signal-specific builder_query schema variants into a single
queryEnvelopeBuilder whose aggregations are a trace/log/metric union
(BuilderAggregation), so `type` maps 1:1 to one variant. Tag QueryEnvelope with
x-signoz-discriminator (propertyName: type); attachDiscriminators promotes it to
a real OpenAPI 3 discriminator so oapi-codegen emits ValueByDiscriminator and
generated clients can dispatch the union mechanically.

Runtime decoding is unchanged — UnmarshalJSON still dispatches builder queries
by signal. The generated frontend client and its dashboard-v2 consumers need a
follow-up to adopt the discriminated union.

* chore(api): regenerate openapi spec for the QueryEnvelope discriminator

Frontend client regeneration is deferred to the follow-up that adapts the
dashboard-v2 query consumers to the discriminated union.

* chore(api): regenerate frontend client for the QueryEnvelope discriminator

Matches the discriminator added in 120de27c8 and the openapi spec in
4d00c0f09: QueryEnvelopeDTO is now a discriminated union keyed on `type`,
with the builder variant collapsed and BuilderAggregation typed.

WIP: the dashboard-v2 queryV5 consumers (buildQueryRangeRequest,
persesQueryAdapters, prepareScalarTables) don't yet compile against the
discriminated union, so a whole-project `tsgo --noEmit` fails until the
follow-up commit adapts them. Committed with --no-verify intentionally.

* feat(querybuilder): make the QueryEnvelope discriminator generator-friendly

Two fixes so oapi-codegen/skaff can dispatch the QueryEnvelope union:
- Pin each variant's `type` to a plain-string enum (it was a $ref to the typed
  QueryType enum, which made oapi-codegen's `v.Type = "builder_query"`
  assignment fail to compile).
- Replace the builder variant's aggregation union with a signal-discriminated
  builderQuerySpec — a oneOf of QueryBuilderQuery[Trace|Log|Metric] keyed on the
  existing (already plain-string-pinned) `signal` field. The three aggregation
  structs stay separate, mirroring dashboardtypes.BuilderQuerySpec.

builder_join's aggregations stay a discriminator-less oneOf (JoinAggregation)
for now — deferred, since a join has no signal to discriminate on.

* chore(api): regenerate openapi spec for the QueryEnvelope discriminator fix

QueryEnvelope now has a plain-string `type` discriminator; builder_query nests a
signal-discriminated BuilderQuerySpec. Frontend client regeneration is deferred
to the single FE pass.

* chore(api): regenerate frontend client for the QueryEnvelope discriminator fix

Matches the plain-string two-level discriminator (cf5b2556e / openapi
1da75be19): QueryEnvelopeDTO discriminates on `type`; builder_query nests a
signal-discriminated BuilderQuerySpecDTO over the three QueryBuilderQuery[T].

WIP: the dashboard-v2 queryV5 consumers (buildQueryRangeRequest,
persesQueryAdapters, prepareScalarTables) still don't compile against the
discriminated union, so a whole-project `tsgo --noEmit` fails until the single
FE pass adapts them. Committed with --no-verify intentionally.

* fix(querybuilder): make QueryEnvelope discriminator `type` required so apitypes compile

The plain-string pinning was the wrong lever — the blocker is the pointer, not
the enum's underlying type. An optional discriminator field renders as `*T`, and
oapi-codegen's From<Variant> assigns the discriminator string literal directly,
which only compiles on a non-pointer field. Mark `type` required:"true" on each
variant (renders non-pointer, like dashboardtypes_layout's required `kind`), and
drop the pinQueryType plain-string override. The QueryType enum is unchanged.

* chore(api): regenerate openapi spec for the required `type` discriminator

QueryEnvelope variants now mark `type` required so the generated apitypes
compile. Frontend client regeneration follows.

* chore(api): regenerate frontend client for the required `type` discriminator

Matches the required-`type` fix (65afd890f / openapi b8d528666): QueryEnvelope
variants mark `type` required so the apitypes compile.

WIP: the dashboard-v2 queryV5 consumers still don't compile against the
discriminated union; a whole-project `tsgo --noEmit` fails until the single FE
pass adapts them. Committed with --no-verify intentionally.

* chore(querybuilder): defer builder_join until it has a proper aggregation discriminator

The join aggregation oneOf (trace/log/metric) has no discriminator — trace and
log aggregations are byte-identical, and a join carries no `signal` to dispatch
on (unlike a builder query) — so code generators can't map it. Comment out
JoinAggregation (with a TODO for when full join support lands), revert
QueryBuilderJoin.Aggregations to []any, and drop the builder_join variant from
QueryEnvelope's discriminated union (oneOf + `type` mapping). Runtime decoding of
builder_join is unchanged.

* chore(api): regenerate openapi spec — builder_join deferred out of the union

* chore(api): regenerate frontend client — builder_join deferred

Matches ee6228946 / openapi ac1255f7b: the builder_join variant (and its
JoinAggregation/QueryBuilderJoin DTOs) are dropped from QueryEnvelopeDTO while
joins are deferred.

WIP: the dashboard-v2 queryV5 consumers still don't compile against the
discriminated union; a whole-project `tsgo --noEmit` fails until the single FE
pass adapts them. Committed with --no-verify intentionally.

* fix(dashboards-v2): adapt queryV5 consumers to the discriminated QueryEnvelope union

The generated QueryEnvelopeDTO is now a discriminated union — orval splits `type`
into per-variant enums — so comparing/constructing against the shared
QueryTypeDTO no longer type-checks. Route the type/spec logic through the
hand-rolled QueryEnvelope model (plain-string `type`, typed `spec`) and cast to
the generated DTO at the wire boundary (reusing the existing toMapperEnvelopes
bridge). No behavior change; the queryV5 tests pass.

* refactor(dashboards-v2): compare envelope discriminator via generated enums

Replace the string-literal `type` checks with the generated per-variant
discriminator enums (Querybuildertypesv5QueryEnvelope{Builder,PromQL,ClickHouseSQL}DTOType).
They compare directly against `envelope.type` (no cast) and narrow the union,
so no magic strings and no hand-rolled QueryEnvelope routing for the type check.

* refactor(dashboards-v2): cast only the envelope spec in toQueryEnvelopes

plugin.spec is the un-narrowed plugin-spec union, so the construction needs to
pick out the specific spec — but casting the whole array `as unknown as
QueryEnvelopeDTO[]` also discarded the type-check on the `type` discriminator.
Cast just `spec` to the variant spec (single `as`, mirroring the CompositeQuery
case), keeping `type` and the array type-checked against QueryEnvelopeDTO.

* refactor(dashboards-v2): drop the as-unknown-as casts from queryV5 consumers

Discriminator narrowing makes envelope.spec typed, so the double casts I added
when adapting these files reduce to single `as` or none:
- extractClickhouseQueryNames: cast-free (ClickHouseQueryDTO has `name`).
- withBarStepInterval: read spec.stepInterval directly; the rebuilt spec keeps a
  single `as BuilderQuerySpecDTO` (spreading the optional union drops required
  `signal`).
- withPagination: single `as` on the rebuilt spec, same reason.
- extractAggregationsPerQuery: single `as` only on the heterogeneous aggregations.
- hasRunnableQueries: single `as QuerySpecView` (its boolean .filter doesn't
  narrow, and the view exposes signal-as-string + metricName).

No `as unknown as` remain in the files this PR touched.

* refactor(qbv5): inline discriminator helpers, trim schema comments

Inline the x-signoz-discriminator construction directly into the
builderQuerySpec and QueryEnvelope PrepareJSONSchema methods, dropping the
signozDiscriminatorKey const and the schemaRef/markDiscriminator helpers.
Trim verbose PrepareJSONSchema doc comments to 1-2 lines each.

Pure refactor: generated openapi.yml is unchanged.

* fix(qbv5): make variable value schema non-nullable

The ALL selection of a dynamic variable sends the marker string __all__,
not null, so the generated value schema should not allow null. Regenerate
the OpenAPI spec and frontend clients to drop the nullable type.
2026-06-26 10:09:00 +00:00
115 changed files with 1373 additions and 2578 deletions

View File

@@ -3755,10 +3755,16 @@ components:
type:
type: string
url:
nullable: true
type: string
required:
- type
- code
- message
- url
- errors
- retry
- suggestions
type: object
ErrorsResponseerroradditional:
properties:
@@ -3768,11 +3774,17 @@ components:
items:
type: string
type: array
required:
- message
- suggestions
type: object
ErrorsResponseretryjson:
nullable: true
properties:
delay:
$ref: '#/components/schemas/TimeDuration'
required:
- delay
type: object
FactoryResponse:
properties:
@@ -5701,6 +5713,18 @@ components:
format: double
type: number
type: object
Querybuildertypesv5BuilderQuerySpec:
discriminator:
mapping:
logs: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
metrics: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
traces: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
propertyName: signal
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
type: object
Querybuildertypesv5ClickHouseQuery:
properties:
disabled:
@@ -5800,7 +5824,10 @@ components:
properties:
name:
type: string
value: {}
value:
oneOf:
- type: number
- type: string
type: object
Querybuildertypesv5FunctionName:
enum:
@@ -5849,7 +5876,11 @@ components:
properties:
key:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
value: {}
value:
oneOf:
- type: string
- type: number
- type: boolean
type: object
Querybuildertypesv5LimitBy:
properties:
@@ -6172,39 +6203,29 @@ components:
type: array
type: object
Querybuildertypesv5QueryEnvelope:
discriminator:
mapping:
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
clickhouse_sql: '#/components/schemas/Querybuildertypesv5QueryEnvelopeClickHouseSQL'
promql: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
propertyName: type
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderTrace'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderLog'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderMetric'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeClickHouseSQL'
properties:
spec: {}
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
type: object
Querybuildertypesv5QueryEnvelopeBuilderLog:
Querybuildertypesv5QueryEnvelopeBuilder:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
type: object
Querybuildertypesv5QueryEnvelopeBuilderMetric:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
type: object
Querybuildertypesv5QueryEnvelopeBuilderTrace:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
$ref: '#/components/schemas/Querybuildertypesv5BuilderQuerySpec'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
properties:
@@ -6212,6 +6233,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeFormula:
properties:
@@ -6219,6 +6242,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopePromQL:
properties:
@@ -6226,6 +6251,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5PromQuery'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeTraceOperator:
properties:
@@ -6233,6 +6260,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderTraceOperator'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryRangeRequest:
description: Request body for the v5 query range endpoint. Supports builder
@@ -6436,7 +6465,17 @@ components:
properties:
type:
$ref: '#/components/schemas/Querybuildertypesv5VariableType'
value: {}
value:
oneOf:
- type: string
- type: number
- type: boolean
- items:
oneOf:
- type: string
- type: number
- type: boolean
type: array
type: object
Querybuildertypesv5VariableType:
enum:

View File

@@ -36,6 +36,55 @@ var (
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
### Message
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
### Url
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
```go
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
WithUrl("https://signoz.io/docs/...")
```
### Additional
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
#### Message
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
#### Suggestions
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithAdditional("field `field` not found")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
```
### Retry
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
```go
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
WithRetryAfter(5 * time.Second)
```
### Suggestions
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
```
## Show me some examples
### Using the error

View File

@@ -143,6 +143,10 @@ func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*autht
return provider.pkgAuthzService.List(ctx, orgID)
}
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
return provider.pkgAuthzService.Collect(ctx, orgID)
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
}
@@ -370,7 +374,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
for _, cb := range provider.onBeforeRoleDelete {
if err := cb(ctx, orgID, id); err != nil {
if err := cb(ctx, orgID, id, role.Name); err != nil {
return err
}
}

View File

@@ -2178,16 +2178,21 @@ export interface ErrorsResponseerroradditionalDTO {
/**
* @type string
*/
message?: string;
message: string;
/**
* @type array
*/
suggestions?: string[];
suggestions: string[];
}
export interface ErrorsResponseretryjsonDTO {
delay?: TimeDurationDTO;
}
export type ErrorsResponseretryjsonDTOAnyOf = {
delay: TimeDurationDTO;
};
/**
* @nullable
*/
export type ErrorsResponseretryjsonDTO = ErrorsResponseretryjsonDTOAnyOf | null;
export interface ErrorsJSONDTO {
/**
@@ -2197,24 +2202,24 @@ export interface ErrorsJSONDTO {
/**
* @type array
*/
errors?: ErrorsResponseerroradditionalDTO[];
errors: ErrorsResponseerroradditionalDTO[];
/**
* @type string
*/
message: string;
retry?: ErrorsResponseretryjsonDTO;
retry: ErrorsResponseretryjsonDTO | null;
/**
* @type array
*/
suggestions?: string[];
suggestions: string[];
/**
* @type string
*/
type?: string;
type: string;
/**
* @type string
* @type string,null
*/
url?: string;
url: string | null;
}
export interface AuthtypesOrgSessionContextDTO {
@@ -3427,12 +3432,14 @@ export interface Querybuildertypesv5FilterDTO {
expression?: string;
}
export type Querybuildertypesv5FunctionArgDTOValue = number | string;
export interface Querybuildertypesv5FunctionArgDTO {
/**
* @type string
*/
name?: string;
value?: unknown;
value?: Querybuildertypesv5FunctionArgDTOValue;
}
export enum Querybuildertypesv5FunctionNameDTO {
@@ -4265,26 +4272,21 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
}
export enum Querybuildertypesv5QueryTypeDTO {
export type Querybuildertypesv5BuilderQuerySpecDTO =
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO;
export enum Querybuildertypesv5QueryEnvelopeBuilderDTOType {
builder_query = 'builder_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderTraceDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5QueryEnvelopeBuilderLogDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5QueryEnvelopeBuilderMetricDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO;
type?: Querybuildertypesv5QueryTypeDTO;
export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
spec?: Querybuildertypesv5BuilderQuerySpecDTO;
/**
* @type string
* @enum builder_query
*/
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
@@ -4319,9 +4321,16 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
order?: Querybuildertypesv5OrderByDTO[];
}
export enum Querybuildertypesv5QueryEnvelopeFormulaDTOType {
builder_formula = 'builder_formula',
}
export interface Querybuildertypesv5QueryEnvelopeFormulaDTO {
spec?: Querybuildertypesv5QueryBuilderFormulaDTO;
type?: Querybuildertypesv5QueryTypeDTO;
/**
* @type string
* @enum builder_formula
*/
type: Querybuildertypesv5QueryEnvelopeFormulaDTOType;
}
export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
@@ -4382,9 +4391,16 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
stepInterval?: Querybuildertypesv5StepDTO;
}
export enum Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType {
builder_trace_operator = 'builder_trace_operator',
}
export interface Querybuildertypesv5QueryEnvelopeTraceOperatorDTO {
spec?: Querybuildertypesv5QueryBuilderTraceOperatorDTO;
type?: Querybuildertypesv5QueryTypeDTO;
/**
* @type string
* @enum builder_trace_operator
*/
type: Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType;
}
export interface Querybuildertypesv5PromQueryDTO {
@@ -4411,9 +4427,16 @@ export interface Querybuildertypesv5PromQueryDTO {
step?: Querybuildertypesv5StepDTO;
}
export enum Querybuildertypesv5QueryEnvelopePromQLDTOType {
promql = 'promql',
}
export interface Querybuildertypesv5QueryEnvelopePromQLDTO {
spec?: Querybuildertypesv5PromQueryDTO;
type?: Querybuildertypesv5QueryTypeDTO;
/**
* @type string
* @enum promql
*/
type: Querybuildertypesv5QueryEnvelopePromQLDTOType;
}
export interface Querybuildertypesv5ClickHouseQueryDTO {
@@ -4435,40 +4458,24 @@ export interface Querybuildertypesv5ClickHouseQueryDTO {
query?: string;
}
export enum Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType {
clickhouse_sql = 'clickhouse_sql',
}
export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
spec?: Querybuildertypesv5ClickHouseQueryDTO;
type?: Querybuildertypesv5QueryTypeDTO;
/**
* @type string
* @enum clickhouse_sql
*/
type: Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType;
}
export type Querybuildertypesv5QueryEnvelopeDTO =
| (Querybuildertypesv5QueryEnvelopeBuilderTraceDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeBuilderLogDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeBuilderMetricDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeFormulaDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeTraceOperatorDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopePromQLDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
});
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeFormulaDTO
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
| Querybuildertypesv5QueryEnvelopePromQLDTO
| Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO;
/**
* Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.
@@ -6805,9 +6812,11 @@ export interface MetricsexplorertypesInspectMetricsRequestDTO {
start: number;
}
export type Querybuildertypesv5LabelDTOValue = string | number | boolean;
export interface Querybuildertypesv5LabelDTO {
key?: TelemetrytypesTelemetryFieldKeyDTO;
value?: unknown;
value?: Querybuildertypesv5LabelDTOValue;
}
export interface Querybuildertypesv5BucketDTO {
@@ -7417,9 +7426,20 @@ export enum Querybuildertypesv5VariableTypeDTO {
custom = 'custom',
text = 'text',
}
export type Querybuildertypesv5VariableItemDTOValueOneOfItem =
| string
| number
| boolean;
export type Querybuildertypesv5VariableItemDTOValue =
| string
| number
| boolean
| Querybuildertypesv5VariableItemDTOValueOneOfItem[];
export interface Querybuildertypesv5VariableItemDTO {
type?: Querybuildertypesv5VariableTypeDTO;
value?: unknown;
value?: Querybuildertypesv5VariableItemDTOValue;
}
export type Querybuildertypesv5QueryRangeRequestDTOVariables = {
@@ -7467,6 +7487,13 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum Querybuildertypesv5QueryTypeDTO {
builder_query = 'builder_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface RenderErrorResponseDTO {
error: ErrorsJSONDTO;
/**

View File

@@ -58,6 +58,7 @@
}
.metrics-table-container {
padding-bottom: 48px;
.ant-table {
margin-left: -16px;
margin-right: -16px;

View File

@@ -84,11 +84,12 @@ export function KeyboardHotkeysProvider({
}
const target = event.target as HTMLElement;
const isCodeMirrorEditor =
(target as HTMLElement).closest('.cm-editor') !== null;
const isCodeMirrorEditor = target.closest('.cm-editor') !== null;
const isMonacoEditor = target.closest('.monaco-editor') !== null;
if (
IGNORE_INPUTS.includes((target as HTMLElement).tagName.toLowerCase()) ||
isCodeMirrorEditor
IGNORE_INPUTS.includes(target.tagName.toLowerCase()) ||
isCodeMirrorEditor ||
isMonacoEditor
) {
return;
}

View File

@@ -1,9 +1,11 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
@@ -18,20 +20,10 @@ interface ConfigPaneProps {
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
/**
* Active query type from the query-builder provider (the selected tab). Drives which
* panel types the visualization switcher disables — read from the provider, not the
* spec, because a new panel's spec has no query until staged.
*/
queryType?: EQueryType;
/** Panel's resolved series, provided to sections that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
}
/**
@@ -44,16 +36,15 @@ function ConfigPane({
panelKind,
spec,
onChangeSpec,
onChangePanelKind,
queryType,
legendSeries,
tableColumns,
stepInterval,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
const signal = getBuilderQueries(spec.queries || [])[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.
@@ -104,10 +95,6 @@ function ConfigPane({
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
/>
))}
</div>

View File

@@ -1,6 +0,0 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,64 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
queryType?: EQueryType;
/** Panel's current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or datasource is incompatible with it — resolved
* through the capabilities guard. The datasource is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType: queryType ?? EQueryType.QUERY_BUILDER,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,
};
});
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value as PanelKind)}
/>
</div>
);
}
export default PanelTypeSwitcher;

View File

@@ -1,122 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
// Query-type support per kind: List is Query-Builder-only; Table/Pie drop PromQL.
const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/ListPanel': [EQueryType.QUERY_BUILDER],
'signoz/TablePanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
}
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
}
describe('PanelTypeSwitcher', () => {
beforeEach(() => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
);
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current datasource', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
openDropdown();
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
});
it('does not disable any type when the datasource is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
onChange={jest.fn()}
/>,
);
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
});
it('disables Query-Builder-only kinds under PromQL even without a datasource', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
});
});

View File

@@ -1,73 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelTypeDisabledReason } from '../utils';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, metrics } = TelemetrytypesSignalDTO;
describe('getPanelTypeDisabledReason', () => {
it('returns undefined for a supported combination', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
label: 'Time Series',
}),
).toBeUndefined();
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: logs,
label: 'List',
}),
).toBeUndefined();
});
it('explains an unsupported query type', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
label: 'List',
}),
).toBe("List isn't available for PromQL queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: CLICKHOUSE,
label: 'List',
}),
).toBe("List isn't available for ClickHouse queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TablePanel',
queryType: PROM,
label: 'Table',
}),
).toBe("Table isn't available for PromQL queries");
});
it('explains an unsupported datasource', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: metrics,
label: 'List',
}),
).toBe("List doesn't support metrics data");
});
it('prefers the query-type reason when both are incompatible', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
signal: metrics,
label: 'List',
}),
).toBe("List isn't available for PromQL queries");
});
});

View File

@@ -1,46 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import {
isQueryTypeSupported,
isSignalSupported,
} from '../../../Panels/capabilities';
import type { PanelKind } from '../../../Panels/types/panelKind';
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.QUERY_BUILDER]: 'Query Builder',
[EQueryType.CLICKHOUSE]: 'ClickHouse',
[EQueryType.PROM]: 'PromQL',
};
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
[TelemetrytypesSignalDTO.metrics]: 'metrics',
};
/**
* Why a panel kind can't be selected for the current query type / datasource, or
* `undefined` when it can. Drives both the type switcher's disabled state and its
* tooltip, so the two never disagree. The query-type reason takes precedence (it's the
* outer choice): query types have no datasource, so the signal only matters in builder.
*/
export function getPanelTypeDisabledReason({
kind,
queryType,
signal,
label,
}: {
kind: PanelKind;
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
if (!isQueryTypeSupported(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
return `${label} doesn't support ${SIGNAL_LABEL[signal]} data`;
}
return undefined;
}

View File

@@ -7,9 +7,7 @@ import {
SECTION_METADATA,
type SectionConfig,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { LegendSeries } from '../../hooks/useLegendSeries';
import type { TableColumnOption } from '../../hooks/useTableColumns';
import { resolveSectionEditor } from '../sectionRegistry';
@@ -25,13 +23,6 @@ interface SectionSlotProps {
tableColumns: TableColumnOption[];
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
signal?: TelemetrytypesSignalDTO;
/** Current panel kind + switch handler, for the visualization section's type switcher. */
panelKind: PanelKind;
onChangePanelKind: (kind: PanelKind) => void;
/** Active query type, for the type switcher's disabled rule (Query-Builder-only kinds). */
queryType?: EQueryType;
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
}
/**
@@ -47,10 +38,6 @@ function SectionSlot({
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
queryType,
stepInterval,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
@@ -73,12 +60,7 @@ function SectionSlot({
.formatting?.unit;
return (
<SettingsSection
title={title}
icon={<Icon size={15} />}
// Open Visualization by default so the type switcher is visible.
defaultOpen={config.kind === 'visualization'}
>
<SettingsSection title={title} icon={<Icon size={15} />}>
<Component
value={get(spec)}
controls={controls}
@@ -87,10 +69,6 @@ function SectionSlot({
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
/>
</SettingsSection>
);

View File

@@ -26,15 +26,13 @@ function SettingsSection({
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
return (
<section className={styles.section}>
<button
type="button"
className={styles.header}
aria-expanded={isOpen}
data-testid={`config-section-${serializedTitle}`}
data-testid={`config-section-${title}`}
onClick={(): void => setIsOpen((prev) => !prev)}
>
{icon && (

View File

@@ -21,7 +21,6 @@ function renderConfigPane(
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
legendSeries: [],
tableColumns: [],
...overrides,
@@ -57,8 +56,6 @@ describe('ConfigPane', () => {
it('renders the Formatting section for a kind that declares it', () => {
renderConfigPane();
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
expect(
screen.getByTestId('config-section-formatting-&-units'),
).toBeInTheDocument();
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
.group {
width: 100%;
width: min(350px, 100%);
}
.segment {

View File

@@ -8,11 +8,3 @@
align-items: center;
gap: 9px;
}
// Wraps a tooltip-bearing option so the hover target fills the row and still receives
// pointer events when the option is disabled (antd dims it but doesn't block events).
.tooltipTrigger {
display: block;
width: 100%;
pointer-events: auto;
}

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from 'react';
import { Select, Tooltip } from 'antd';
import { Select } from 'antd';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
@@ -8,11 +7,7 @@ import styles from './ConfigSelect.module.scss';
export interface ConfigSelectItem {
value: string;
label: string;
/** A `SegmentIconName` string (resolved to a glyph), or an arbitrary icon node. */
icon?: ReactNode;
disabled?: boolean;
/** Hover hint shown on the option — typically the reason a disabled item is disabled. */
tooltip?: string;
icon?: SegmentIconName;
}
interface ConfigSelectProps {
@@ -43,31 +38,17 @@ function ConfigSelect({
placeholder={placeholder}
onChange={onChange}
virtual={false}
options={items.map((item) => {
const content = item.icon ? (
options={items.map((item) => ({
value: item.value,
label: item.icon ? (
<span className={styles.item}>
{typeof item.icon === 'string' ? (
<SegmentIcon name={item.icon as SegmentIconName} />
) : (
item.icon
)}
<SegmentIcon name={item.icon} />
{item.label}
</span>
) : (
item.label
);
return {
value: item.value,
disabled: item.disabled,
label: item.tooltip ? (
<Tooltip title={item.tooltip} placement="top">
<span className={styles.tooltipTrigger}>{content}</span>
</Tooltip>
) : (
content
),
};
})}
),
}))}
/>
);
}

View File

@@ -157,16 +157,6 @@ export interface ErasedSectionDescriptor {
// The panel's telemetry signal; read by editors that fetch field-key
// suggestions scoped to it (List column picker).
signal?: unknown;
// Current panel kind + switch handler; read by the visualization section's
// type switcher.
panelKind?: unknown;
onChangePanelKind?: unknown;
// Active query type; read by the visualization section's type switcher to
// disable kinds that can't be authored in it (List is Query-Builder-only).
queryType?: unknown;
// Query step interval (seconds); read by chart appearance to floor the
// span-gaps threshold.
stepInterval?: unknown;
}>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;

View File

@@ -3,14 +3,3 @@
flex-direction: column;
gap: 8px;
}
.thresholdField {
display: flex;
flex-direction: column;
gap: 8px;
}
.thresholdPrefix {
padding-right: 4px;
opacity: 0.6;
}

View File

@@ -1,4 +1,6 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
@@ -9,7 +11,6 @@ import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContaine
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import DisconnectValuesField from './DisconnectValuesField';
import styles from './ChartAppearanceSection.module.scss';
@@ -76,11 +77,16 @@ function ChartAppearanceSection({
value,
controls,
onChange,
stepInterval,
}: SectionEditorProps<'chartAppearance'> & {
/** Query step interval (seconds) for the span-gaps threshold floor. */
stepInterval?: number;
}): JSX.Element {
}: SectionEditorProps<'chartAppearance'>): JSX.Element {
// `spanGaps.fillLessThan` is a stringified seconds threshold: empty means "connect
// every gap" (the chart default), a number means "only bridge gaps shorter than this".
const handleSpanGaps = (e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
onChange({
...value,
spanGaps: raw === '' ? undefined : { ...value?.spanGaps, fillLessThan: raw },
});
};
return (
<>
{controls.lineStyle && (
@@ -140,12 +146,16 @@ function ChartAppearanceSection({
)}
{controls.spanGaps && (
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"
value={value?.spanGaps}
stepInterval={stepInterval}
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
/>
<div className={styles.field}>
<Typography.Text>Connect gaps shorter than (s)</Typography.Text>
<Input
data-testid="panel-editor-v2-span-gaps"
type="number"
placeholder="All gaps"
value={value?.spanGaps?.fillLessThan ?? ''}
onChange={handleSpanGaps}
/>
</div>
)}
</>
);

View File

@@ -1,93 +0,0 @@
import { useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import styles from './ChartAppearanceSection.module.scss';
const DEFAULT_THRESHOLD = '1m';
const MODE_NEVER = 'never';
const MODE_THRESHOLD = 'threshold';
const MODE_OPTIONS = [
{ value: MODE_NEVER, label: 'Never' },
{ value: MODE_THRESHOLD, label: 'Threshold' },
];
interface DisconnectValuesFieldProps {
testId: string;
value: DashboardtypesSpanGapsDTO | undefined;
/** Query step interval (seconds): seeds the default threshold and floors it. */
stepInterval?: number;
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
}
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
function defaultDuration(stepInterval?: number): string {
return stepInterval && stepInterval > 0
? rangeUtil.secondsToHms(stepInterval)
: DEFAULT_THRESHOLD;
}
/**
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
* (only bridge gaps shorter than a duration). The threshold persists as a
* duration string in `spanGaps.fillLessThan` ("10m", "5s") — the wire format the
* backend expects.
*/
function DisconnectValuesField({
testId,
value,
stepInterval,
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
const isThreshold = !!duration;
// Remember the last threshold so toggling Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState(
duration ?? defaultDuration(stepInterval),
);
useEffect(() => {
if (duration) {
setLastDuration(duration);
}
}, [duration]);
const handleMode = (mode: string): void => {
onChange(
mode === MODE_THRESHOLD
? { ...value, fillLessThan: lastDuration }
: undefined,
);
};
return (
<>
<div className={styles.field}>
<Typography.Text>Disconnect values</Typography.Text>
<ConfigSegmented
testId={testId}
value={isThreshold ? MODE_THRESHOLD : MODE_NEVER}
items={MODE_OPTIONS}
onChange={handleMode}
/>
</div>
{isThreshold && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={lastDuration}
minValue={stepInterval}
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
/>
</div>
)}
</>
);
}
export default DisconnectValuesField;

View File

@@ -1,94 +0,0 @@
import { type ChangeEvent, useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Callout } from '@signozhq/ui/callout';
import { Input } from 'antd';
import styles from './ChartAppearanceSection.module.scss';
interface DisconnectValuesThresholdInputProps {
testId: string;
/** Current threshold as a duration string (e.g. "1m") — the stored wire value. */
value: string;
/** Smallest allowed threshold (the query step interval), in seconds. */
minValue?: number;
onChange: (duration: string) => void;
}
/**
* Duration input for the span-gaps threshold: shows/accepts and reports a human
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
* `fillLessThan` (a bare number is read as seconds). It is only parsed to seconds
* to validate against the query step interval. Invalid entries, or values below
* that floor, surface an inline error and are not committed (V1 parity).
*/
function DisconnectValuesThresholdInput({
testId,
value,
minValue,
onChange,
}: DisconnectValuesThresholdInputProps): JSX.Element {
const [text, setText] = useState(value);
const [error, setError] = useState<string | null>(null);
// Resync the displayed duration when the committed value changes upstream.
useEffect(() => {
setText(value);
setError(null);
}, [value]);
const commit = (raw: string): void => {
if (!raw) {
return;
}
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
return;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
// Store the user's duration string as-is — the wire format the backend wants.
onChange(raw);
};
return (
<div className={styles.thresholdField}>
<Input
data-testid={testId}
type="text"
status={error ? 'error' : undefined}
prefix={<span className={styles.thresholdPrefix}>&gt;</span>}
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setText(e.target.value);
if (error) {
setError(null);
}
}}
onBlur={(e): void => commit(e.currentTarget.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
commit(e.currentTarget.value);
}
}}
/>
{error && (
<Callout type="error" size="small" showIcon>
{error}
</Callout>
)}
</div>
);
}
export default DisconnectValuesThresholdInput;

View File

@@ -108,24 +108,9 @@ describe('ChartAppearanceSection', () => {
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
});
it('defaults to "Never" (no threshold) and hides the threshold input', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Never')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('switching to "Threshold" seeds the default 1m threshold', () => {
it('writes a span-gaps threshold and clears it when emptied', () => {
const onChange = jest.fn();
render(
const { rerender } = render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
@@ -133,103 +118,23 @@ describe('ChartAppearanceSection', () => {
/>,
);
fireEvent.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '1m' },
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '60' },
});
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '60' },
});
});
it('stores the threshold as a duration string (not seconds)', () => {
const onChange = jest.fn();
render(
rerender(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
value={{ spanGaps: { fillLessThan: '60' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
expect(input).toHaveValue('1m');
fireEvent.change(input, { target: { value: '5m' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '5m' },
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '' },
});
});
it('stores the entry verbatim (bare number kept as typed, not converted)', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
fireEvent.change(input, { target: { value: '300' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '300' },
});
});
it('switching back to "Never" clears the threshold', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Never'));
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
it('shows an error and does not commit an invalid duration', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
fireEvent.change(input, { target: { value: 'abc' } });
fireEvent.blur(input);
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('rejects a threshold below the query step interval', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '2m' } }}
controls={{ spanGaps: true }}
stepInterval={120}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
// 1m (60s) is below the 2m (120s) step interval.
fireEvent.change(input, { target: { value: '1m' } });
fireEvent.blur(input);
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -123,25 +123,6 @@ describe('ComparisonThresholdsSection', () => {
]);
});
it('lets the value input be cleared instead of snapping back to 0', async () => {
const user = userEvent.setup();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
const valueInput = screen.getByTestId('comparison-threshold-value-0');
// Regression: clearing used to coerce "" → 0 and refill the field, so the
// seeded value could never be removed.
await user.clear(valueInput);
expect(valueInput).toHaveValue(null);
// And a fresh value can be typed into the now-empty field.
await user.type(valueInput, '5');
expect(valueInput).toHaveValue(5);
});
it('does not commit edits when Discard is clicked', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
@@ -17,12 +16,6 @@ function ThresholdValueField({
value,
onChange,
}: ThresholdValueFieldProps): JSX.Element {
const [raw, setRaw] = useState(String(value));
useEffect(() => {
setRaw((prev) => (Number(prev) === value ? prev : String(value)));
}, [value]);
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
@@ -30,11 +23,8 @@ function ThresholdValueField({
data-testid={testId}
type="number"
placeholder="Value"
value={raw}
onChange={(e): void => {
setRaw(e.target.value);
onChange(e.target.value);
}}
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
);

View File

@@ -1,55 +1,26 @@
import { Typography } from '@signozhq/ui/typography';
import {
DashboardtypesTimePreferenceDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../../Panels/types/panelKind';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<'visualization'> & {
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Active query type, forwarded by SectionSlot — scopes the switcher's disabled types. */
queryType?: EQueryType;
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
signal?: TelemetrytypesSignalDTO;
};
/**
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
* flag, so a kind only renders — and only writes — the fields its spec supports.
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
* writes — the visualization fields its spec actually supports.
*/
function VisualizationSection({
value,
controls,
onChange,
panelKind,
onChangePanelKind,
queryType,
signal,
}: VisualizationSectionProps): JSX.Element {
}: SectionEditorProps<'visualization'>): JSX.Element {
return (
<>
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
queryType={queryType}
signal={signal}
onChange={onChangePanelKind}
/>
)}
{controls.timePreference && (
<div className={styles.field}>
<Typography.Text>Panel time preference</Typography.Text>

View File

@@ -4,15 +4,6 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
import VisualizationSection from '../VisualizationSection';
// The type switcher resolves each kind's supported signals + query types; stub it so
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),
}));
// Open the antd Select by clicking its selector, then pick the option by label.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
@@ -26,12 +17,7 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{
switchPanelKind: true,
timePreference: true,
stacking: true,
fillSpans: true,
}}
controls={{ timePreference: true, stacking: true, fillSpans: true }}
onChange={jest.fn()}
/>,
);
@@ -49,10 +35,7 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{
switchPanelKind: true,
timePreference: true,
}}
controls={{ timePreference: true }}
onChange={jest.fn()}
/>,
);
@@ -73,10 +56,7 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{
switchPanelKind: true,
timePreference: true,
}}
controls={{ timePreference: true }}
onChange={onChange}
/>,
);
@@ -94,10 +74,7 @@ describe('VisualizationSection', () => {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
stackedBarChart: false,
}}
controls={{
switchPanelKind: true,
stacking: true,
}}
controls={{ stacking: true }}
onChange={onChange}
/>,
);
@@ -115,10 +92,7 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={{ fillSpans: false }}
controls={{
switchPanelKind: true,
fillSpans: true,
}}
controls={{ fillSpans: true }}
onChange={onChange}
/>,
);
@@ -127,43 +101,4 @@ describe('VisualizationSection', () => {
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
const onChangePanelKind = jest.fn();
render(
<VisualizationSection
value={undefined}
controls={{ switchPanelKind: true }}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={onChangePanelKind}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-type-switcher'),
).toBeInTheDocument();
await pickOption('panel-editor-v2-type-switcher', 'Table');
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
});
it('hides the type switcher when switchPanelKind is not set', () => {
render(
<VisualizationSection
value={undefined}
controls={{
switchPanelKind: false,
timePreference: true,
}}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-type-switcher'),
).not.toBeInTheDocument();
});
});

View File

@@ -8,35 +8,23 @@ import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
panelKind: PanelKind;
/** The panel's current datasource; selects per-signal query-builder field rules. */
signal?: TelemetrytypesSignalDTO;
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
@@ -53,15 +41,12 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelKind,
signal,
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
footer,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -89,16 +74,13 @@ function PanelEditorQueryBuilder({
[onStageRunQuery],
);
// Query-builder field visibility for this kind + signal (e.g. List hides step
// interval / having). QueryBuilderV2 ignores it for list view (its internal config
// wins), but the guard stays the single declared source — see ListPanel definition.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => getHiddenQueryBuilderFields(panelKind, signal),
[panelKind, signal],
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
[],
);
const items = useMemo(() => {
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -145,7 +127,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode]);
}, [panelType, filterConfigs, isDarkMode]);
return (
<div

View File

@@ -1,145 +0,0 @@
import { render, screen } from '@testing-library/react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
const mockQueryBuilderV2 = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
QueryBuilderV2: (props: unknown): null => {
mockQueryBuilderV2(props);
return null;
},
}));
jest.mock(
'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock(
'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock('container/QueryBuilder/components/RunQueryBtn/RunQueryBtn', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('components/TextToolTip', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('assets/Dashboard/PromQl', () => ({
__esModule: true,
default: (): null => null,
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
function renderBuilder(
panelKind: string,
signal?: TelemetrytypesSignalDTO,
): void {
render(
<PanelEditorQueryBuilder
panelKind={panelKind as never}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}
onCancelQuery={jest.fn()}
/>,
);
}
function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
filterConfigs: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
return calls[calls.length - 1][0];
}
describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});
it('shows only the Query Builder tab for the List kind', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows Query Builder + ClickHouse but not PromQL for the Table kind', () => {
renderBuilder('signoz/TablePanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all three tabs for the Time Series kind', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
});
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});
it('passes empty field config + non-list flag for a non-list kind', () => {
renderBuilder('signoz/TimeSeriesPanel', TelemetrytypesSignalDTO.metrics);
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('graph');
expect(props.isListViewPanel).toBe(false);
expect(props.filterConfigs).toStrictEqual({});
});
it('hides step interval / having and sets body-contains for List + logs', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('list');
expect(props.isListViewPanel).toBe(true);
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
it('additionally hides limit for List + traces', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.traces);
const props = lastQueryBuilderProps();
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
});

View File

@@ -1,98 +0,0 @@
import {
TelemetrytypesSignalDTO,
type DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../ListColumnsEditor/selectFields', () => ({
defaultColumnsForSignal: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
const mockDefaultColumnsForSignal =
defaultColumnsForSignal as unknown as jest.Mock;
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('getSwitchedPluginSpec', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
});
const old = specWith({
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
axes: { logScale: true },
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
// Type-specific config from the old kind is dropped.
expect((result as { axes?: unknown }).axes).toBeUndefined();
});
it('does not carry formatting when the new kind has no formatting section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({ formatting: { unit: 'ms' } });
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toBeUndefined();
});
it('seeds List columns from the signal when switching into a List', () => {
const columns = [{ name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
TelemetrytypesSignalDTO.logs,
);
expect(result.selectFields).toBe(columns);
});
it('includes the kind section defaults (e.g. legend position)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'legend', controls: { position: true } }],
});
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/PieChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.legend?.position).toBe('bottom');
});
});

View File

@@ -1,67 +0,0 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
type DefaultPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
/**
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
* at the boundary.
*/
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
* List seeds the current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
export function getSwitchedPluginSpec(
oldSpec: DashboardtypesPanelSpecDTO,
newKind: PanelKind,
signal: TelemetrytypesSignalDTO,
): SwitchedPluginSpec {
const sections = getPanelDefinition(newKind)?.sections ?? [];
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
if (sections.some((section) => section.kind === 'formatting')) {
const oldFormatting = (
oldSpec.plugin.spec as {
formatting?: PanelFormattingSlice;
}
).formatting;
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
...(oldFormatting?.decimalPrecision !== undefined && {
decimalPrecision: oldFormatting.decimalPrecision,
}),
};
if (Object.keys(carried).length > 0) {
result.formatting = carried;
}
}
if (sections.some((section) => section.kind === 'columns')) {
const columns = defaultColumnsForSignal(signal);
if (columns.length > 0) {
result.selectFields = columns;
}
}
return result;
}

View File

@@ -1,194 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
}));
jest.mock('../../getSwitchedPluginSpec', () => ({
getSwitchedPluginSpec: jest.fn(),
}));
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
getBuilderQueries: jest.fn(),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const LIST_PLUGIN_SPEC = { list: true } as unknown;
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const TRANSFORMED = {
id: 'transformed',
queryType: 'builder',
} as unknown as Query;
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SWITCHED_SPEC = { switched: true } as unknown;
function makeSpec(
kind: string,
pluginSpec: unknown,
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind, spec: pluginSpec },
queries,
} as unknown as DashboardtypesPanelSpecDTO;
}
const tableSpec = makeSpec(
'signoz/TablePanel',
TABLE_PLUGIN_SPEC,
TABLE_QUERIES,
);
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
function builderState(currentQuery: Query): {
currentQuery: Query;
redirectWithQueryBuilderData: jest.Mock;
} {
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
}
describe('usePanelTypeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
mockToPerses.mockReturnValue(CONVERTED);
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
// The guard owns coercion (tested in capabilities.test.ts); here it always
// resolves to Query Builder so the coerced type flows into handleQueryChange.
mockResolveQueryType.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
expect(setSpec).not.toHaveBeenCalled();
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
});
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
expect(setSpec).toHaveBeenCalledTimes(1);
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
expect(next.plugin.kind).toBe('signoz/ListPanel');
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
expect(next.queries).toBe(CONVERTED);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind…
expect(mockResolveQueryType).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
);
// …and the resolved type ('builder') flows into the query rebuild.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).queryType).toBe('builder');
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelTypeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table for List (stashes Table in its pristine state).
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// Parent re-renders as a List panel; the builder now holds the List query.
state = builderState(listQuery);
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
// Switch back to Table → restored from the stash, not re-transformed.
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restored.plugin.kind).toBe('signoz/TablePanel');
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restored.queries).toBe(TABLE_QUERIES);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
// The restore path must not run the query transform again.
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,7 +40,7 @@ export function useLegendSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel.spec.queries || []);
const builderQueries = getBuilderQueries(panel?.spec?.queries || []);
const byLabel = new Map<string, string>();
series.forEach((s) => {

View File

@@ -1,134 +0,0 @@
import { useCallback, useRef } from 'react';
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[] | null;
builderQuery: Query;
}
interface UsePanelTypeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelTypeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
*/
export function usePanelTypeSwitch({
spec,
panelType,
setSpec,
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries ?? null,
builderQuery: query,
});
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
// `createDefaultPanel`). The surrounding spec stays fully typed.
const buildSpec = (
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
queries: DashboardtypesQueryDTO[] | null,
): DashboardtypesPanelSpecDTO => ({
...currentSpec,
plugin: {
...currentSpec.plugin,
kind: newKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
});
// Revisit → restore the stash verbatim (the reversibility path).
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
redirectWithQueryBuilderData(cached.builderQuery);
return;
}
// First visit → coerce the query type if the new kind disallows it, then
// rebuild the builder query for the new type.
const queryType = resolveQueryType(newKind, query.queryType);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
panelTypeRef.current,
);
const signal = getBuilderQueries(currentSpec.queries || [])[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(transformed, newPanelType),
),
);
redirectWithQueryBuilderData(transformed);
},
[setSpec, redirectWithQueryBuilderData],
);
return { onChangePanelKind };
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import {
ResizableHandle,
ResizablePanel,
@@ -11,7 +11,6 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
@@ -19,7 +18,6 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
@@ -31,7 +29,6 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
@@ -68,10 +65,6 @@ function PanelEditorContainer({
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
@@ -120,9 +113,6 @@ function PanelEditorContainer({
signal: defaultSignal,
});
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
@@ -156,14 +146,6 @@ function PanelEditorContainer({
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSave = useCallback(async (): Promise<void> => {
try {
// Bake the live query into the spec so unstaged edits are saved too.
@@ -215,8 +197,7 @@ function PanelEditorContainer({
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelKind={fullKind}
signal={listSignal}
panelType={panelType}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
@@ -245,11 +226,8 @@ function PanelEditorContainer({
panelKind={draft.spec.plugin.kind}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -1,175 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupported,
isSignalSupported,
resolveQueryType,
} from '../capabilities';
import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
};
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/TimeSeriesPanel': [metrics, logs, traces],
'signoz/BarChartPanel': [metrics, logs, traces],
'signoz/NumberPanel': [metrics, logs, traces],
'signoz/HistogramPanel': [metrics, logs, traces],
'signoz/PieChartPanel': [metrics, logs, traces],
'signoz/TablePanel': [metrics, logs, traces],
// List renders raw rows; metrics produce no row data.
'signoz/ListPanel': [logs, traces],
};
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query type support', () => {
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
expect(getSupportedQueryTypes(kind)).toStrictEqual(
EXPECTED_QUERY_TYPES[kind],
);
});
it('Table and Pie do not support PromQL', () => {
expect(isQueryTypeSupported('signoz/TablePanel', PROM)).toBe(false);
expect(isQueryTypeSupported('signoz/PieChartPanel', PROM)).toBe(false);
});
it('List only supports Query Builder', () => {
expect(isQueryTypeSupported('signoz/ListPanel', QUERY_BUILDER)).toBe(true);
expect(isQueryTypeSupported('signoz/ListPanel', CLICKHOUSE)).toBe(false);
expect(isQueryTypeSupported('signoz/ListPanel', PROM)).toBe(false);
});
});
describe('signal support', () => {
it.each(ALL_KINDS)('declares the expected signals for %s', (kind) => {
expect(getSupportedSignals(kind)).toStrictEqual(EXPECTED_SIGNALS[kind]);
});
it('List excludes metrics', () => {
expect(isSignalSupported('signoz/ListPanel', metrics)).toBe(false);
expect(isSignalSupported('signoz/ListPanel', logs)).toBe(true);
expect(isSignalSupported('signoz/ListPanel', traces)).toBe(true);
});
});
describe('isPanelCombinationValid', () => {
it('accepts a supported triad', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
}),
).toBe(true);
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: logs,
}),
).toBe(true);
});
it('rejects an unsupported query type', () => {
expect(
isPanelCombinationValid({ kind: 'signoz/ListPanel', queryType: PROM }),
).toBe(false);
expect(
isPanelCombinationValid({ kind: 'signoz/TablePanel', queryType: PROM }),
).toBe(false);
});
it('rejects an unsupported signal when one is given', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: metrics,
}),
).toBe(false);
});
it('ignores signal when none is given (ClickHouse/PromQL have no datasource)', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
}),
).toBe(true);
});
});
describe('resolveQueryType', () => {
it('keeps a supported query type', () => {
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(resolveQueryType('signoz/ListPanel', QUERY_BUILDER)).toBe(
QUERY_BUILDER,
);
});
it('coerces an unsupported query type to the first supported one', () => {
// PromQL → List has no PromQL, falls back to its first (and only) type.
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
});
});
describe('getHiddenQueryBuilderFields', () => {
it('returns {} for kinds that declare no field rules', () => {
expect(getHiddenQueryBuilderFields('signoz/TimeSeriesPanel')).toStrictEqual(
{},
);
expect(getHiddenQueryBuilderFields('signoz/TablePanel', logs)).toStrictEqual(
{},
);
});
// Mirrors QueryBuilderV2's internal listViewLogFilterConfigs — the guard is the
// single source of truth for these values.
it('hides step interval / having and sets body-contains for List + logs', () => {
expect(getHiddenQueryBuilderFields('signoz/ListPanel', logs)).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
// Mirrors listViewTracesFilterConfigs — traces additionally hide `limit`.
it('additionally hides limit for List + traces', () => {
expect(
getHiddenQueryBuilderFields('signoz/ListPanel', traces),
).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
it('falls back to the default rule when no signal is given', () => {
expect(getHiddenQueryBuilderFields('signoz/ListPanel')).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
});
});

View File

@@ -1,94 +0,0 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import type { FilterConfigsPartial } from './types/panelCapabilities';
import type { PanelKind } from './types/panelKind';
/**
* The single deterministic guard for V2 dashboards. Every "what works with what"
* question — panel kind × query type × signal, and which query-builder fields a kind
* hides — is answered here by reading each kind's declared capabilities from the panel
* registry. Adding a new kind means declaring its capabilities once in its definition;
* these functions then cover it automatically. Pure and side-effect free.
*/
/** Signals (datasources) a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
): TelemetrytypesSignalDTO[] {
return getPanelDefinition(kind).supportedSignals;
}
export function isSignalSupported(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): boolean {
return getSupportedSignals(kind).includes(signal);
}
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getPanelDefinition(kind).supportedQueryTypes;
}
export function isQueryTypeSupported(
kind: PanelKind,
queryType: EQueryType,
): boolean {
return getSupportedQueryTypes(kind).includes(queryType);
}
/**
* Master guard: is this panel kind renderable with this query type (and, when a
* datasource is known, this signal)? Signal is only meaningful in builder mode —
* ClickHouse/PromQL queries have no datasource — so it's validated only when given.
*/
export function isPanelCombinationValid({
kind,
queryType,
signal,
}: {
kind: PanelKind;
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
if (!isQueryTypeSupported(kind, queryType)) {
return false;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
return false;
}
return true;
}
/**
* The query type to use for a kind given a `preferred` one: keep it if the kind
* supports it, otherwise fall back to the kind's first supported type. Used when
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
* List panel coerces to Query Builder).
*/
export function resolveQueryType(
kind: PanelKind,
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
return supported.includes(preferred) ? preferred : supported[0];
}
/**
* Query-builder field visibility for a kind + signal: the kind's `default` rule with
* its per-signal overrides merged over it (signal wins). `{}` when the kind declares
* nothing, i.e. the builder shows every field.
*/
export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal?: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getPanelDefinition(kind).queryBuilderFields;
if (!rule) {
return {};
}
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}

View File

@@ -2,7 +2,6 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
@@ -14,11 +13,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
actions: {
view: true,
edit: true,

View File

@@ -3,10 +3,7 @@ import type { SectionConfig } from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true, stacking: true },
},
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true } },

View File

@@ -2,7 +2,6 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
@@ -14,11 +13,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
actions: {
view: true,
edit: true,

View File

@@ -3,10 +3,6 @@ import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true },
},
{
kind: 'legend',
controls: { position: true },

View File

@@ -2,8 +2,6 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
@@ -14,21 +12,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
// Raw rows have no aggregation, so step interval / having never apply, and the
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
// list configs — the capabilities guard is the single source for both.
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
},
[TelemetrytypesSignalDTO.traces]: {
limit: { isHidden: true, isDisabled: true },
},
},
sections,
actions: {
view: true,

View File

@@ -1,8 +1,3 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true },
},
];
export const sections: SectionConfig[] = [];

View File

@@ -2,7 +2,6 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
@@ -14,11 +13,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { variant: 'comparison' } },
{ kind: 'contextLinks' },

View File

@@ -2,7 +2,6 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
@@ -14,7 +13,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
actions: {
view: true,
edit: true,

View File

@@ -3,10 +3,7 @@ import type { SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
// Legend `colors` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'contextLinks' },

View File

@@ -2,7 +2,6 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
@@ -14,7 +13,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -4,10 +4,7 @@ import type { SectionConfig } from '../../types/sections';
// single column set). It exposes the per-panel time scope, formatting (decimals +
// per-column units), per-column thresholds, and context links.
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
{ kind: 'thresholds', controls: { variant: 'table' } },
{ kind: 'contextLinks' },

View File

@@ -2,7 +2,6 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
@@ -14,11 +13,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
},
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true, colors: true } },

View File

@@ -1,20 +0,0 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
/**
* Query-builder field-visibility config a panel kind can declare, mirroring the
* shape `QueryBuilderV2` consumes via its `filterConfigs` prop. Derived from that
* prop type (the underlying `FilterConfigs` isn't exported) so the two never drift.
*/
export type FilterConfigsPartial = NonNullable<
QueryBuilderProps['filterConfigs']
>;
/**
* Per-signal query-builder field rules for a panel kind. `default` applies to every
* signal; a per-signal entry is merged over it (signal wins). The capabilities guard
* resolves this into a single `FilterConfigsPartial` via `getHiddenQueryBuilderFields`.
*/
export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;

View File

@@ -1,11 +1,9 @@
import type { ComponentType } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { QueryBuilderFieldRule } from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/**
@@ -37,12 +35,7 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
/** Signals (datasources) this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Query-builder fields this kind hides/disables, optionally per signal. */
queryBuilderFields?: QueryBuilderFieldRule;
actions: PanelActionCapabilities;
}

View File

@@ -89,11 +89,8 @@ export interface SectionControls {
spanGaps?: boolean;
};
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
// switchPanelKindthe visualization-type switcher (every kind, so you can switch
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
// 0 (TimeSeries).
// stackingstackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
visualization: {
switchPanelKind: boolean;
timePreference?: boolean;
stacking?: boolean;
fillSpans?: boolean;
@@ -131,7 +128,7 @@ export type SectionConfig =
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
// live in the ConfigPane section registry.
export const SECTION_METADATA = {
formatting: { title: 'Formatting & Units', icon: Hash },
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: Layers },
chartAppearance: { title: 'Chart appearance', icon: Palette },

View File

@@ -1,39 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { resolveSignal } from '../getBuilderQueries';
function builderQuery(signal: string): DashboardtypesQueryDTO {
return {
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal } } },
} as unknown as DashboardtypesQueryDTO;
}
const promqlQuery = {
spec: { plugin: { kind: 'signoz/PromQuery', spec: { query: 'up' } } },
} as unknown as DashboardtypesQueryDTO;
describe('resolveSignal', () => {
const DEFAULT = TelemetrytypesSignalDTO.metrics;
it("uses the first builder query's signal when present", () => {
expect(
resolveSignal([builderQuery('logs')], DEFAULT),
).toBe('logs');
});
it("prefers the builder signal over the default", () => {
expect(resolveSignal([builderQuery('traces')], DEFAULT)).toBe(
'traces',
);
});
it('falls back to the default signal when there are no queries (new panel)', () => {
expect(resolveSignal([], DEFAULT)).toBe('metrics');
expect(resolveSignal(null, DEFAULT)).toBe('metrics');
});
it('stays undefined when queries exist but none are builder queries (PromQL/ClickHouse)', () => {
expect(resolveSignal([promqlQuery], DEFAULT)).toBeUndefined();
});
});

View File

@@ -1,22 +0,0 @@
import { resolveSpanGaps } from '../resolvers';
describe('resolveSpanGaps', () => {
it('spans all gaps (true) when unset', () => {
expect(resolveSpanGaps(undefined)).toBe(true);
expect(resolveSpanGaps('')).toBe(true);
});
it('parses a duration string into seconds', () => {
expect(resolveSpanGaps('5s')).toBe(5);
expect(resolveSpanGaps('10m')).toBe(600);
expect(resolveSpanGaps('1h')).toBe(3600);
});
it('tolerates a bare seconds number (back-compat)', () => {
expect(resolveSpanGaps('600')).toBe(600);
});
it('falls back to true for unparseable input', () => {
expect(resolveSpanGaps('abc')).toBe(true);
});
});

View File

@@ -1,4 +1,3 @@
import { rangeUtil } from '@grafana/data';
import {
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
@@ -39,10 +38,9 @@ export function resolveDecimalPrecision(
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
* bare seconds number for back-compat.
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty/missing → span all gaps (default); numeric → forward the threshold
* so uPlot only bridges short runs of nulls.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
@@ -50,10 +48,8 @@ export function resolveSpanGaps(
if (!fillLessThan) {
return true;
}
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)
? rangeUtil.intervalToSeconds(fillLessThan)
: Number(fillLessThan);
return Number.isFinite(seconds) && seconds > 0 ? seconds : true;
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
}
/** Legend position; missing/unknown falls back to `BOTTOM` (chart default, V1 parity). */

View File

@@ -1,7 +1,4 @@
import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
@@ -36,21 +33,3 @@ export function getBuilderQueries(
});
return flattened;
}
/**
* Datasource signal scoping panel-type compatibility (List needs logs/traces, not
* metrics): the builder query's signal if present; else `defaultSignal` for a new
* panel (queries empty until edited); else undefined for PromQL/ClickHouse.
*/
export function resolveSignal(
queries: DashboardtypesQueryDTO[] | null,
defaultSignal: TelemetrytypesSignalDTO,
): TelemetrytypesSignalDTO | undefined {
const builderSignal = getBuilderQueries(queries ?? [])[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
if (builderSignal) {
return builderSignal;
}
return queries?.length ? undefined : defaultSignal;
}

View File

@@ -52,8 +52,6 @@ function section(
}
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
const baseArgs = {
panelId: 'panel-1',
@@ -179,49 +177,6 @@ describe('usePanelActionItems', () => {
});
});
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).toContain('move-to-root');
});
it('"Move out of section" moves the panel to the untitled root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
const moveOut = result.current.items.find(
(i) => 'key' in i && i.key === 'move-to-root',
);
(moveOut as { onClick: () => void }).onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 1,
toLayoutIndex: 0,
});
});
it('hides "Move out of section" when the panel already sits in the root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('hides "Move out of section" when every section is titled (no root)', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const del = result.current.items.find(

View File

@@ -4,7 +4,6 @@ import {
CloudDownload,
Copy,
FolderInput,
FolderOutput,
Fullscreen,
PenLine,
Trash2,
@@ -24,10 +23,7 @@ import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
@@ -41,66 +37,6 @@ function notImplementedYet(feature: string): void {
alert(`${feature} option clicked`);
}
interface MoveItemsArgs {
sections: DashboardSection[];
currentLayoutIndex: number;
panelId: string;
movePanel: (args: MovePanelArgs) => Promise<void>;
}
/**
* The "Move to section" submenu (other titled sections) plus a direct "Move out
* of section" to the untitled root, shown only when the panel sits in a titled
* section and a root section exists to receive it.
*/
function buildMoveItems({
sections,
currentLayoutIndex,
panelId,
movePanel,
}: MoveItemsArgs): MenuItem[] {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
const items: MenuItem[] = [
{
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
},
];
const rootSection = sections.find((s) => !s.title);
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
items.push({
key: 'move-to-root',
label: 'Move out of section',
icon: <FolderOutput size={14} />,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: rootSection.layoutIndex,
}),
});
}
return items;
}
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
@@ -219,15 +155,31 @@ export function usePanelActionItems({
});
}
const moveGroup: MenuItem[] =
canMove && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [];
const moveGroup: MenuItem[] = [];
if (canMove && panelActions) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
);
moveGroup.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: panelActions.currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
});
}
const deleteGroup: MenuItem[] =
canDelete && panelActions

View File

@@ -26,7 +26,13 @@ describe('panelStatusFromError', () => {
code: 'invalid_query',
message: 'Query is invalid',
url: 'https://docs/err',
errors: [{ message: 'missing aggregation' }, { message: 'bad filter' }],
errors: [
{ message: 'missing aggregation', suggestions: [] },
{ message: 'bad filter', suggestions: [] },
],
retry: null,
suggestions: [],
type: '',
});
expect(panelStatusFromError(error)).toStrictEqual({
@@ -48,7 +54,15 @@ describe('panelStatusFromError', () => {
it('omits docsUrl when the API error has no url', () => {
const error = axiosErrorWith(
{ code: 'x', message: 'y', url: '', errors: [] },
{
code: 'x',
message: 'y',
url: '',
errors: [],
retry: null,
suggestions: [],
type: '',
},
StatusCodes.INTERNAL_SERVER_ERROR,
);

View File

@@ -8,7 +8,7 @@ import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind) => void;
onSelect: (pluginKind: PanelKind) => void;
}
function PanelTypeSelectionModal({
@@ -25,17 +25,17 @@ function PanelTypeSelectionModal({
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
{PANEL_TYPES.map((type) => (
<Button
key={panelKind}
key={type.pluginKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
<Icon size={14} />
{label}
{type.icon}
{type.label}
</Button>
))}
</div>

View File

@@ -1,24 +0,0 @@
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import type { PanelType } from './types';
export const PANEL_TYPES: PanelType[] = [
{
panelKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
Icon: ChartLine,
},
{ panelKind: 'signoz/NumberPanel', label: 'Number', Icon: Hash },
{ panelKind: 'signoz/TablePanel', label: 'Table', Icon: Table },
{ panelKind: 'signoz/BarChartPanel', label: 'Bar Chart', Icon: BarChart },
{ panelKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
{ panelKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
{ panelKind: 'signoz/ListPanel', label: 'List', Icon: List },
];

View File

@@ -0,0 +1,36 @@
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import type { PanelType } from './types';
export const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];

View File

@@ -1,16 +1,7 @@
import type { IconSize } from '@signozhq/icons';
import type { ComponentType, SVGProps } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
size?: number | IconSize;
strokeWidth?: number;
};
export interface PanelType {
panelKind: PanelKind;
pluginKind: PanelKind;
label: string;
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
icon: JSX.Element;
}

View File

@@ -63,10 +63,9 @@ describe('useClonePanel', () => {
op: 'add',
path: '/spec/layouts/0/spec/items/-',
value: {
// Same dimensions as the source panel (p1: 8x5). The last row is
// full (8 + 4 = 12 cols), so the 8-wide clone wraps to a fresh row
// at the section bottom: max(y + height) = 5.
// Same dimensions as the source panel (p1: 8x5).
x: 0,
// Bottom of the section: max(y + height) over existing items = 5.
y: 5,
width: 8,
height: 5,
@@ -76,27 +75,6 @@ describe('useClonePanel', () => {
]);
});
it('places the clone beside the last row when it fits', async () => {
const oneNarrowItem: DashboardSection[] = [
{
id: 'section-0',
layoutIndex: 0,
title: 'Overview',
repeatVariable: undefined,
items: [{ id: 'p1', x: 0, y: 0, width: 4, height: 5, panel: sourcePanel }],
},
];
const { result } = renderHook(() =>
useClonePanel({ sections: oneNarrowItem }),
);
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatch.mock.calls[0][1];
// Room in the last row (4 + 4 = 8 ≤ 12 cols) → sits to the right at y:0.
expect(ops[1].value).toMatchObject({ x: 4, y: 0, width: 4, height: 5 });
});
it('deep-copies the spec — the cloned value is not the same object reference', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));

View File

@@ -5,11 +5,7 @@ import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import {
addPanelToSectionOps,
findFreeSlot,
panelRef,
} from '../../../patchOps';
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -24,9 +20,8 @@ export interface ClonePanelArgs {
/**
* Duplicates a panel: deep-copies the source spec under a fresh id and drops a
* same-size grid item into the section via `findFreeSlot` (beside the last row
* if it fits, else a fresh row), as one atomic patch. Mirrors V1's clone
* (verbatim spec copy, no rename).
* same-size grid item at the bottom of the section, as one atomic patch. Mirrors
* V1's clone (verbatim spec copy, no rename).
*/
export function useClonePanel({
sections,
@@ -43,7 +38,10 @@ export function useClonePanel({
}
const newPanelId = uuid();
const { x, y } = findFreeSlot(section.items, source.width);
const nextY = section.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const clone = patchDashboardV2(
{ id: dashboardId },
@@ -52,8 +50,8 @@ export function useClonePanel({
panel: cloneDeep(source.panel),
layoutIndex,
item: {
x,
y,
x: 0,
y: nextY,
width: source.width,
height: source.height,
content: { $ref: panelRef(newPanelId) },

View File

@@ -107,7 +107,7 @@ describe('createPanelOps', () => {
expect(value.y).toBe(6);
});
it('falls back to the root (first) section when no index is requested', () => {
it('falls back to the last section when no index is requested', () => {
const layouts = [section([]), section([item(0, 6)])];
const ops = createPanelOps({
layouts,
@@ -116,11 +116,11 @@ describe('createPanelOps', () => {
panel,
});
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
});
it('falls back to the root (first) section when the requested index is out of range', () => {
const layouts = [section([item(0, 6)]), section([])];
it('falls back to the last section when the requested index is out of range', () => {
const layouts = [section([])];
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});

View File

@@ -121,7 +121,7 @@ export function addPanelToSectionOps({
interface CreatePanelOpsArgs {
/** Current sections, used to resolve the target and the next free row. */
layouts: DashboardtypesLayoutDTO[];
/** Preferred section (from a section's "Add panel" trigger); falls back to the root (first) section. */
/** Preferred section (from the "Add panel" trigger); falls back to the last. */
layoutIndex: number | undefined;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -132,16 +132,13 @@ const NEW_PANEL_SIZE = { width: 6, height: 6 };
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
const GRID_COLS = 12;
/** Minimal placement fields shared by grid-item DTOs and flattened `GridItem`s. */
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
/**
* Placement for a new grid item: drop it right of the last row if there's room,
* else wrap to a fresh row at the bottom. Only the last row is considered (items
* sharing the greatest top-y); gaps in earlier rows are left alone.
*/
export function findFreeSlot(
items: PlacedItem[],
function findFreeSlot(
items: DashboardGridItemDTO[],
width: number,
): { x: number; y: number } {
const w = Math.min(width, GRID_COLS);
@@ -166,8 +163,8 @@ export function findFreeSlot(
/**
* Ops to persist a brand-new panel (editor save path): resolve the target
* section (requested index if valid, else the root/first section, else a
* freshly-created one) and place the panel via `findFreeSlot`.
* section (requested index if valid, else last, else a freshly-created one) and
* place the panel via `findFreeSlot`.
*/
export function createPanelOps({
layouts,
@@ -177,17 +174,14 @@ export function createPanelOps({
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
let targetIndex: number;
let items: DashboardGridItemDTO[];
if (layoutIndex !== undefined && layouts[layoutIndex] !== undefined) {
// Explicit section — a section's own "New Panel" trigger.
targetIndex = layoutIndex;
items = layouts[layoutIndex]?.spec.items ?? [];
} else if (layouts.length > 0) {
// No section specified (toolbar "New Panel") → the root (first) section.
targetIndex = 0;
items = layouts[0]?.spec.items ?? [];
} else {
const requested =
layoutIndex !== undefined && layouts[layoutIndex] !== undefined
? layoutIndex
: layouts.length - 1;
let targetIndex = requested;
let items: DashboardGridItemDTO[] = layouts[requested]?.spec.items ?? [];
if (targetIndex < 0) {
// No sections yet — create an untitled one and target it.
ops.push(addSectionOp(''));
targetIndex = 0;

View File

@@ -1,11 +1,16 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5ClickHouseQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryTypeDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -66,19 +71,26 @@ export function toQueryEnvelopes(
case 'signoz/CompositeQuery':
return (plugin.spec as Querybuildertypesv5CompositeQueryDTO).queries ?? [];
case 'signoz/BuilderQuery':
// plugin.spec is the (un-narrowed) plugin-spec union, so pick the builder
// spec out of it — mirroring the CompositeQuery case above.
return [
{
type: Querybuildertypesv5QueryTypeDTO.builder_query,
spec: plugin.spec,
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
},
];
case 'signoz/PromQLQuery':
return [{ type: Querybuildertypesv5QueryTypeDTO.promql, spec: plugin.spec }];
return [
{
type: Querybuildertypesv5QueryEnvelopePromQLDTOType.promql,
spec: plugin.spec as Querybuildertypesv5PromQueryDTO,
},
];
case 'signoz/ClickHouseSQL':
return [
{
type: Querybuildertypesv5QueryTypeDTO.clickhouse_sql,
spec: plugin.spec,
type: Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql,
spec: plugin.spec as Querybuildertypesv5ClickHouseQueryDTO,
},
];
case 'signoz/Formula':
@@ -134,14 +146,22 @@ function withBarStepInterval(
): Querybuildertypesv5QueryEnvelopeDTO[] {
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
return envelopes.map((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
if (spec.stepInterval) {
if (envelope.spec?.stepInterval) {
return envelope;
}
return { ...envelope, spec: { ...spec, stepInterval } };
return {
...envelope,
spec: {
...envelope.spec,
stepInterval,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
});
}
@@ -154,12 +174,19 @@ function withPagination(
{ offset, limit }: { offset: number; limit: number },
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return envelope;
}
return {
...envelope,
spec: { ...(envelope.spec as Record<string, unknown>), offset, limit },
spec: {
...envelope.spec,
offset,
limit,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
});
}
@@ -238,7 +265,8 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
const metricsSpecs = envelopes
.filter(
(envelope) =>
envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query,
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
)
.map((envelope) => envelope.spec as QuerySpecView)
.filter((spec) => spec.signal === 'metrics');

View File

@@ -7,7 +7,9 @@ import type {
import {
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
Querybuildertypesv5QueryTypeDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
@@ -46,17 +48,24 @@ const toGeneratedEnvelopes = (
const isBuilderQueryEnvelope = (
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean => envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query;
): boolean =>
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
export function deriveQueryType(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): EQueryType {
if (envelopes.some((e) => e.type === Querybuildertypesv5QueryTypeDTO.promql)) {
if (
envelopes.some(
(e) => e.type === Querybuildertypesv5QueryEnvelopePromQLDTOType.promql,
)
) {
return EQueryType.PROM;
}
if (
envelopes.some(
(e) => e.type === Querybuildertypesv5QueryTypeDTO.clickhouse_sql,
(e) =>
e.type ===
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql,
)
) {
return EQueryType.CLICKHOUSE;

View File

@@ -1,10 +1,12 @@
import type {
Querybuildertypesv5ColumnDescriptorDTO,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelTable, PanelTableColumn } from './types';
@@ -26,15 +28,15 @@ export function extractAggregationsPerQuery(
): AggregationsPerQuery {
const perQuery: AggregationsPerQuery = {};
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return;
}
const spec = envelope.spec as {
name?: string;
aggregations?: AggregationView[];
};
const spec = envelope.spec;
if (spec?.name && spec.aggregations) {
perQuery[spec.name] = spec.aggregations;
perQuery[spec.name] = spec.aggregations as AggregationView[];
}
});
return perQuery;
@@ -52,13 +54,14 @@ export function extractClickhouseQueryNames(
): Set<string> {
const names = new Set<string>();
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.clickhouse_sql) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql
) {
return;
}
const spec = (envelope as Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO)
.spec;
if (spec?.name) {
names.add(spec.name);
if (envelope.spec?.name) {
names.add(envelope.spec.name);
}
});
return names;

View File

@@ -137,3 +137,13 @@ export function layoutsToSections(
})
.filter((s): s is DashboardSection => s !== null);
}
export function getPanelKindLabel(
panel: DashboardtypesPanelDTO | undefined,
): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) {
return 'unknown';
}
return kind.replace(/^signoz\//, '');
}

2
go.mod
View File

@@ -180,7 +180,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/ClickHouse/ch-go v0.71.0
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Yiling-J/theine-go v0.6.2 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b

View File

@@ -66,6 +66,9 @@ type AuthZ interface {
// Lists all the roles for the organization.
List(context.Context, valuer.UUID) ([]*authtypes.Role, error)
// Collect returns per-org role usage stats for the stats reporter.
Collect(context.Context, valuer.UUID) (map[string]any, error)
// Lists all the roles for the organization filtered by name
ListByOrgIDAndNames(context.Context, valuer.UUID, []string) ([]*authtypes.Role, error)
@@ -92,7 +95,7 @@ type AuthZ interface {
}
// OnBeforeRoleDelete is a callback invoked before a role is deleted.
type OnBeforeRoleDelete func(context.Context, valuer.UUID, valuer.UUID) error
type OnBeforeRoleDelete func(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
type Handler interface {
Create(http.ResponseWriter, *http.Request)

View File

@@ -95,6 +95,15 @@ func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*autht
return provider.store.List(ctx, orgID)
}
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
roles, err := provider.List(ctx, orgID)
if err != nil {
return nil, err
}
return authtypes.NewStatsFromRoles(roles), nil
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
return provider.store.ListByOrgIDAndNames(ctx, orgID, names)
}

View File

@@ -75,6 +75,12 @@ func (b *base) Error() string {
return b.m
}
// Unwrap exposes the wrapped cause so stdlib errors.Is / errors.As can walk
// the chain — e.g. errors.Is(WrapCanceledf(context.Canceled, …), context.Canceled).
func (b *base) Unwrap() error {
return b.e
}
// New returns a base error. It requires type, code and message as input.
func New(t typ, code Code, message string) *base {
return &base{
@@ -337,6 +343,16 @@ func NewTimeoutf(code Code, format string, args ...any) *base {
return Newf(TypeTimeout, code, format, args...)
}
// WrapCanceledf is a wrapper around Wrapf with TypeCanceled.
func WrapCanceledf(cause error, code Code, format string, args ...any) *base {
return Wrapf(cause, TypeCanceled, code, format, args...)
}
// NewCanceledf is a wrapper around Newf with TypeCanceled.
func NewCanceledf(code Code, format string, args ...any) *base {
return Newf(TypeCanceled, code, format, args...)
}
// WrapUnauthenticatedf is a wrapper around Wrapf with TypeUnauthenticated.
func WrapUnauthenticatedf(cause error, code Code, format string, args ...any) *base {
return Wrapf(cause, TypeUnauthenticated, code, format, args...)

View File

@@ -1,6 +1,7 @@
package errors
import (
"context"
"errors" //nolint:depguard
"testing"
"time"
@@ -84,7 +85,7 @@ func TestWithSuggestiveAdditional(t *testing.T) {
assert.Equal(t, []responseerroradditional{
{Message: "field `filed` not found", Suggestions: []string{"did you mean: `field`"}},
}, j.Errors)
assert.Nil(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
assert.Empty(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
}
func TestWithRetryAfter(t *testing.T) {
@@ -106,7 +107,12 @@ func TestAsJSONBaseError(t *testing.T) {
assert.Equal(t, "bad_input", j.Code)
assert.Equal(t, "field foo is bad", j.Message)
assert.Equal(t, "https://docs/bad_input", j.Url)
assert.Equal(t, []responseerroradditional{{Message: "hint1"}, {Message: "hint2"}}, j.Errors)
// A detail with no suggestions carries an empty (non-nil) slice — the
// suggestions field is non-nullable, so it marshals to [] rather than null.
assert.Equal(t, []responseerroradditional{
{Message: "hint1", Suggestions: []string{}},
{Message: "hint2", Suggestions: []string{}},
}, j.Errors)
// InvalidInput auto-applies the after_fix policy via NewInvalidInputf — but
// New (bare constructor) does not. The retry block should reflect that.
@@ -157,9 +163,16 @@ func TestAsJSONRetryBlock(t *testing.T) {
})
}
func TestAsJSONOptionalFieldsOmittedWhenEmpty(t *testing.T) {
func TestAsJSONEmptyWhenNoneSet(t *testing.T) {
// errors and suggestions are non-nullable in the OpenAPI spec, so AsJSON
// leaves them as empty (non-nil) slices when the error carries none — they
// marshal to [] rather than null.
j := AsJSON(New(TypeInternal, MustNewCode("boom"), "boom"))
assert.Nil(t, j.Suggestions, "no suggestions set => Suggestions must be nil so json omitempty drops it")
assert.NotNil(t, j.Suggestions)
assert.Empty(t, j.Suggestions)
assert.NotNil(t, j.Errors)
assert.Empty(t, j.Errors)
}
func TestWithStacktrace(t *testing.T) {
@@ -173,3 +186,16 @@ func TestWithStacktrace(t *testing.T) {
assert.Equal(t, "test_code", code.String())
assert.Equal(t, "panic", message)
}
// Wrapped context sentinels must remain detectable via errors.Is so callers
// that branch on context.Canceled / context.DeadlineExceeded keep working
// after the error passes through one of the signoz Wrap* helpers.
func TestWrapPreservesContextSentinels(t *testing.T) {
canceled := WrapCanceledf(context.Canceled, MustNewCode("canceled"), "op canceled")
assert.True(t, Is(canceled, context.Canceled))
assert.False(t, Is(canceled, context.DeadlineExceeded))
deadline := WrapTimeoutf(context.DeadlineExceeded, MustNewCode("timeout"), "op timed out")
assert.True(t, Is(deadline, context.DeadlineExceeded))
assert.False(t, Is(deadline, context.Canceled))
}

View File

@@ -7,32 +7,29 @@ import (
)
type JSON struct {
Type string `json:"type,omitempty"`
Type string `json:"type" required:"true"`
Code string `json:"code" required:"true"`
Message string `json:"message" required:"true"`
Url string `json:"url,omitempty"`
Errors []responseerroradditional `json:"errors,omitempty"`
Retry *responseretryjson `json:"retry,omitempty"`
Suggestions []string `json:"suggestions,omitempty"`
Url string `json:"url" required:"true" nullable:"true"`
Errors []responseerroradditional `json:"errors" required:"true" nullable:"false"`
Retry *responseretryjson `json:"retry" required:"true" nullable:"true"`
Suggestions []string `json:"suggestions" required:"true" nullable:"false"`
}
type responseretryjson struct {
Delay time.Duration `json:"delay"`
Delay time.Duration `json:"delay" required:"true" nullable:"false"`
}
type responseerroradditional struct {
Message string `json:"message,omitempty"`
Suggestions []string `json:"suggestions,omitempty"`
Message string `json:"message" required:"true"`
Suggestions []string `json:"suggestions" required:"true" nullable:"false"`
}
func AsJSON(cause error) *JSON {
// See if this is an instance of the base error or not
t, c, m, _, u, a := Unwrapb(cause)
rea := make([]responseerroradditional, len(a))
for k, v := range a {
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
}
rea := responseAdditionals(a)
var retry *responseretryjson
if r := retryOf(cause); r != nil {
@@ -46,7 +43,7 @@ func AsJSON(cause error) *JSON {
Url: u,
Errors: rea,
Retry: retry,
Suggestions: suggestionsOf(cause),
Suggestions: nonNilStrings(suggestionsOf(cause)),
}
}
@@ -54,10 +51,7 @@ func AsURLValues(cause error) url.Values {
// See if this is an instance of the base error or not
_, c, m, _, u, a := Unwrapb(cause)
rea := make([]responseerroradditional, len(a))
for k, v := range a {
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
}
rea := responseAdditionals(a)
errors, err := json.Marshal(rea)
if err != nil {
@@ -75,3 +69,20 @@ func AsURLValues(cause error) url.Values {
"errors": {string(errors)},
}
}
func responseAdditionals(a []additional) []responseerroradditional {
rea := make([]responseerroradditional, len(a))
for k, v := range a {
rea[k] = responseerroradditional{Message: v.message, Suggestions: nonNilStrings(v.suggestions)}
}
return rea
}
func nonNilStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}

View File

@@ -5,6 +5,18 @@ import (
"strings"
)
// Nouns name the kind of value a suggestion refers to. Pass one to
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
// "valid <noun> are ..." list consistently across the codebase.
const (
NounFields = "fields"
NounKeys = "keys"
NounServices = "services"
NounQueryTypes = "query types"
NounSignals = "signals"
NounReferences = "references"
)
const (
typoSuggestionThreshold = 0.75
// maxValidReferences caps how many valid references are listed so
@@ -13,17 +25,18 @@ const (
maxValidReferences = 20
)
// SuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
// NewSuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
// when a close match at least typoSuggestionThreshold similar exists) followed
// by the valid-references list.
func SuggestionsOnLevenshteinDistance(invalidInput string, validInputs []string) []string {
// by the valid-references list. noun names the kind of value being suggested
// (e.g. "fields", "keys") and is used to phrase the valid-references list.
func NewSuggestionsOnLevenshteinDistance(invalidInput string, noun string, validInputs []string) []string {
suggestions := make([]string, 0, 2)
if match, ok := ClosestLevenshteinMatch(invalidInput, validInputs); ok {
suggestions = append(suggestions, didYouMean(match))
}
if refs := ValidReferences(validInputs...); refs != "" {
if refs := NewValidReferences(noun, validInputs...); refs != "" {
suggestions = append(suggestions, refs)
}
@@ -52,10 +65,10 @@ func ClosestLevenshteinMatch(input string, candidates []string) (string, bool) {
return "", false
}
// SuggestionsFromFunc formats the string produce returns as a one-element
// NewSuggestionsFromFunc formats the string produce returns as a one-element
// "did you mean: `x`" slice, or nil when it returns the empty string (so callers
// with their own matching strategy compose into a suggestions list cleanly).
func SuggestionsFromFunc(produce func() string) []string {
func NewSuggestionsFromFunc(produce func() string) []string {
s := produce()
if s == "" {
return nil
@@ -64,12 +77,12 @@ func SuggestionsFromFunc(produce func() string) []string {
return []string{didYouMean(s)}
}
// ValidReferences formats values as "valid references: `a`, `b`", capped at
// maxValidReferences with a "(+N more)" suffix. Each value is rendered as its
// own string, an Enum() element's StringValue(), or fmt.Sprint as a fallback.
// It returns "" when there are no values, so callers don't surface a bare
// "valid references: " with nothing after it.
func ValidReferences[T any](values ...T) string {
// NewValidReferences formats values as "valid <noun> are `a`, `b`" (e.g. noun
// "fields", "functions", "keys"), capped at maxValidReferences with a "(+N more)"
// suffix. Each value is rendered as its own string, an Enum() element's
// StringValue(), or fmt.Sprint as a fallback. It returns "" when there are no
// values, so callers don't surface a bare "valid <noun> are" with nothing after it.
func NewValidReferences[T any](noun string, values ...T) string {
if len(values) == 0 {
return ""
}
@@ -97,7 +110,7 @@ func ValidReferences[T any](values ...T) string {
quoted[i] = "`" + r + "`"
}
out := "valid references: " + strings.Join(quoted, ", ")
out := "valid " + noun + " are " + strings.Join(quoted, ", ")
if truncated > 0 {
out += fmt.Sprintf(" (+%d more)", truncated)
}

View File

@@ -6,26 +6,28 @@ import (
"github.com/stretchr/testify/assert"
)
func TestValidReferences(t *testing.T) {
// An empty set returns "" so callers don't surface a bare "valid references: ".
assert.Equal(t, "", ValidReferences[string]())
func TestNewValidReferences(t *testing.T) {
// An empty set returns "" so callers don't surface a bare "valid <noun> are".
assert.Equal(t, "", NewValidReferences[string](NounFields))
assert.Equal(t, "valid references: `a`, `b`", ValidReferences("a", "b"))
// The noun phrases the list, e.g. "valid fields are", "valid keys are".
assert.Equal(t, "valid fields are `a`, `b`", NewValidReferences(NounFields, "a", "b"))
assert.Equal(t, "valid keys are `a`, `b`", NewValidReferences(NounKeys, "a", "b"))
}
func TestSuggestionsOnLevenshteinDistance(t *testing.T) {
// No valid inputs => no suggestions at all (no bare "valid references: ").
assert.Empty(t, SuggestionsOnLevenshteinDistance("foo", nil))
func TestNewSuggestionsOnLevenshteinDistance(t *testing.T) {
// No valid inputs => no suggestions at all (no bare "valid <noun> are").
assert.Empty(t, NewSuggestionsOnLevenshteinDistance("foo", NounFields, nil))
// Close match => did-you-mean plus the valid-references list.
assert.Equal(t,
[]string{"did you mean: `name`", "valid references: `name`, `color`"},
SuggestionsOnLevenshteinDistance("nam", []string{"name", "color"}),
[]string{"did you mean: `name`", "valid fields are `name`, `color`"},
NewSuggestionsOnLevenshteinDistance("nam", NounFields, []string{"name", "color"}),
)
// No close match => valid-references list only.
assert.Equal(t,
[]string{"valid references: `name`, `color`"},
SuggestionsOnLevenshteinDistance("zzzzz", []string{"name", "color"}),
[]string{"valid fields are `name`, `color`"},
NewSuggestionsOnLevenshteinDistance("zzzzz", NounFields, []string{"name", "color"}),
)
}

View File

@@ -3,10 +3,10 @@ package binding
import (
"encoding/json"
"io"
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/jsonschema"
)
const (
@@ -76,7 +76,7 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
return errors.
NewInvalidInputf(errors.CodeInvalidInput, message, field).
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, JSONFieldNames(obj))...)
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, jsonschema.JSONFieldNames(obj))...)
}
}
@@ -86,37 +86,6 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
return nil
}
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
// skipping fields tagged "-" or without a json tag.
func JSONFieldNames(v any) []string {
var fields []string
t := reflect.TypeOf(v)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return fields
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
fieldName := strings.Split(jsonTag, ",")[0]
if fieldName != "" {
fields = append(fields, fieldName)
}
}
return fields
}
// extractUnknownField pulls fieldname out of a `json: unknown field "fieldname"`
// decoder message, or returns "" when the message has no quoted field.
func extractUnknownField(errMsg string) string {

View File

@@ -90,21 +90,21 @@ func TestJSONBinding_BindBody_UnknownFieldSuggestions(t *testing.T) {
body: `{"shape":"round"}`,
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
message: `unknown field "shape"`,
suggestions: []string{"valid references: `name`, `color`"},
suggestions: []string{"valid fields are `name`, `color`"},
},
{
name: "WithContext",
body: `{"shape":"round"}`,
opts: []BindBodyOption{WithDisallowUnknownFields(true), WithUnknownFieldContext("widget spec")},
message: `unknown field "shape" in widget spec`,
suggestions: []string{"valid references: `name`, `color`"},
suggestions: []string{"valid fields are `name`, `color`"},
},
{
name: "NearMatch",
body: `{"nam":"x"}`,
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
message: `unknown field "nam"`,
suggestions: []string{"did you mean: `name`", "valid references: `name`, `color`"},
suggestions: []string{"did you mean: `name`", "valid fields are `name`, `color`"},
},
}

View File

@@ -99,13 +99,13 @@ func TestError(t *testing.T) {
name: "AlreadyExists",
statusCode: http.StatusConflict,
err: errors.New(errors.TypeAlreadyExists, errors.MustNewCode("already_exists"), "already exists").WithUrl("https://already_exists"),
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists"}}`),
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists","errors":[],"retry":null,"suggestions":[]}}`),
},
"/unauthenticated": {
name: "Unauthenticated",
statusCode: http.StatusUnauthorized,
err: errors.New(errors.TypeUnauthenticated, errors.MustNewCode("not_allowed"), "not allowed").WithUrl("https://unauthenticated").WithAdditional("a1", "a2"),
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1"},{"message":"a2"}]}}`),
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1","suggestions":[]},{"message":"a2","suggestions":[]}],"retry":null,"suggestions":[]}}`),
},
}
@@ -177,8 +177,8 @@ func TestErrorRetryAfterHeader(t *testing.T) {
name: "BareErrorNoHeaderNoRetryBlock",
err: errors.New(errors.TypeInternal, errors.MustNewCode("boom"), "boom"),
wantRetryAfter: "",
wantBodyContains: `"code":"boom"`,
wantBodyNotContains: `"retry"`,
wantBodyContains: `"retry":null`,
wantBodyNotContains: `"delay"`,
},
}

38
pkg/jsonschema/fields.go Normal file
View File

@@ -0,0 +1,38 @@
// Package jsonschema holds small reflection helpers shared across packages.
package jsonschema
import (
"reflect"
"strings"
)
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
// skipping fields tagged "-" or without a json tag.
func JSONFieldNames(v any) []string {
var fields []string
t := reflect.TypeOf(v)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return fields
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
fieldName := strings.Split(jsonTag, ",")[0]
if fieldName != "" {
fields = append(fields, fieldName)
}
}
return fields
}

View File

@@ -44,3 +44,7 @@ type Handler interface {
Update(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
}
type Getter interface {
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
}

View File

@@ -0,0 +1,45 @@
package implauthdomain
import (
"context"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type getter struct {
store authtypes.AuthDomainStore
}
func NewGetter(store authtypes.AuthDomainStore) authdomain.Getter {
return &getter{store: store}
}
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error {
domains, err := getter.store.ListByOrgID(ctx, orgID)
if err != nil {
return err
}
referencedBy := make([]string, 0)
for _, domain := range domains {
for _, mappedRole := range domain.AuthDomainConfig().RoleMapping.RoleNames() {
if mappedRole == roleName {
referencedBy = append(referencedBy, domain.StorableAuthDomain().Name)
break
}
}
}
if len(referencedBy) > 0 {
return errors.WithAdditionalf(
errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleHasAuthDomainMappings, "role is referenced by an SSO role mapping, remove it before deleting"),
"referenced by auth domain(s): %s", strings.Join(referencedBy, ", "),
)
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -12,13 +13,18 @@ import (
type module struct {
store authtypes.AuthDomainStore
authNs map[authtypes.AuthNProvider]authn.AuthN
authz authz.AuthZ
}
func NewModule(store authtypes.AuthDomainStore, authNs map[authtypes.AuthNProvider]authn.AuthN) authdomain.Module {
return &module{store: store, authNs: authNs}
func NewModule(store authtypes.AuthDomainStore, authNs map[authtypes.AuthNProvider]authn.AuthN, authz authz.AuthZ) authdomain.Module {
return &module{store: store, authNs: authNs, authz: authz}
}
func (module *module) Create(ctx context.Context, domain *authtypes.AuthDomain) error {
if err := module.validateRoleMapping(ctx, domain); err != nil {
return err
}
return module.store.Create(ctx, domain)
}
@@ -50,6 +56,10 @@ func (module *module) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*au
}
func (module *module) Update(ctx context.Context, domain *authtypes.AuthDomain) error {
if err := module.validateRoleMapping(ctx, domain); err != nil {
return err
}
return module.store.Update(ctx, domain)
}
@@ -74,3 +84,13 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
func (module *module) validateRoleMapping(ctx context.Context, domain *authtypes.AuthDomain) error {
roleNames := domain.AuthDomainConfig().RoleMapping.RoleNames()
if len(roleNames) == 0 {
return nil
}
_, err := module.authz.ListByOrgIDAndNames(ctx, domain.StorableAuthDomain().OrgID, roleNames)
return err
}

View File

@@ -18,7 +18,7 @@ func NewGetter(store serviceaccounttypes.Store) serviceaccount.Getter {
return &getter{store: store}
}
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, _ string) error {
serviceAccounts, err := getter.store.GetServiceAccountsByOrgIDAndRoleID(ctx, orgID, roleID)
if err != nil {
return err

View File

@@ -13,7 +13,7 @@ import (
type Getter interface {
// OnBeforeRoleDelete checks if any service accounts are assigned to the role and rejects deletion if so.
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
}
type Module interface {

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
@@ -29,9 +30,10 @@ type module struct {
authDomain authdomain.Module
tokenizer tokenizer.Tokenizer
orgGetter organization.Getter
authz authz.AuthZ
}
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter) session.Module {
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
return &module{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
authNs: authNs,
@@ -40,6 +42,7 @@ func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.A
authDomain: authDomain,
tokenizer: tokenizer,
orgGetter: orgGetter,
authz: authz,
}
}
@@ -143,15 +146,23 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
}
roleMapping := authDomain.AuthDomainConfig().RoleMapping
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
signozManagedRole := authtypes.MustGetSigNozManagedRoleFromExistingRole(role)
roleAttributeExists := false
if roleMapping != nil && roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
_, err := module.authz.GetByOrgIDAndName(ctx, callbackIdentity.OrgID, authtypes.NormalizeRoleName(callbackIdentity.Role))
if err == nil {
roleAttributeExists = true
}
}
roleNames := roleMapping.NewRolesFromCallbackIdentity(callbackIdentity, roleAttributeExists)
newUser, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, callbackIdentity.OrgID, types.UserStatusActive)
if err != nil {
return "", err
}
newUser, err = module.userSetter.GetOrCreateUser(ctx, newUser, user.WithRoleNames([]string{signozManagedRole}))
newUser, err = module.userSetter.GetOrCreateUser(ctx, newUser, user.WithRoleNames(roleNames))
if err != nil {
return "", err
}

View File

@@ -239,7 +239,7 @@ func (module *getter) VerifyResetPasswordToken(ctx context.Context, token string
return nil
}
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, _ string) error {
users, err := module.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
if err != nil {
return err

View File

@@ -96,7 +96,7 @@ type Getter interface {
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error)
// OnBeforeRoleDelete checks if any users are assigned to the role and rejects deletion if so.
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
// VerifyResetPasswordToken checks if a reset password token exists and is not expired.
VerifyResetPasswordToken(ctx context.Context, token string) error

View File

@@ -81,7 +81,7 @@ func CollisionHandledFinalExpr(
// - it is not a static field
// - the next best thing to do is see if there is a typo
// and suggest a correction
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys)), "field `%s` not found", field.Name)
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys)), "field `%s` not found", field.Name)
return "", nil, wrappedErr
} else {
for _, key := range keysForField {

View File

@@ -300,7 +300,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
var suggestions []string
if len(v.invalid) == 1 {
inv := v.invalid[0]
suggestions = errors.SuggestionsFromFunc(func() string {
suggestions = errors.NewSuggestionsFromFunc(func() string {
match, ok := errors.ClosestLevenshteinMatch(inv, validKeys)
if !ok || strings.Contains(original, inv+"(") || strings.Contains(match, "(") {
return ""
@@ -309,7 +309,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
})
}
suggestions = append(suggestions, errors.ValidReferences(validKeys...))
suggestions = append(suggestions, errors.NewValidReferences(errors.NounReferences, validKeys...))
havingErr := errors.NewInvalidInputf(
errors.CodeInvalidInput,
"Invalid references in `Having` expression: [%s]",
@@ -339,7 +339,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
// multiple errors are surfaced as one additional detail each. If the parser
// produced no message (rare), the top-level message stands on its own.
if len(allSyntaxErrors) == 1 && len(msgs) == 1 {
suggestions := errors.SuggestionsFromFunc(func() string {
suggestions := errors.NewSuggestionsFromFunc(func() string {
return havingSuggestion(allSyntaxErrors[0], original)
})

View File

@@ -593,7 +593,7 @@ func TestRewriteForLogsAndTraces_InOperator(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [ghost]",
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
},
{
name: "IN with end bracked missing",
@@ -655,7 +655,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [unknown_alias]",
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
},
{
name: "typo in identifier suggests closest match",
@@ -666,7 +666,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [totol]",
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
wantSuggestions: []string{"did you mean: `total > 100`", "valid references: `__result`, `__result0`, `count()`, `total`"},
wantSuggestions: []string{"did you mean: `total > 100`", "valid references are `__result`, `__result0`, `count()`, `total`"},
},
{
name: "expression not in column map",
@@ -677,7 +677,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [sum]",
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`"},
},
{
name: "one valid one invalid reference",
@@ -688,7 +688,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [ghost]",
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
},
{
name: "__result ambiguous with multiple aggregations",
@@ -700,7 +700,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [__result]",
wantAdditional: []string{"Valid references are: [__result0, __result1, count(), sum(bytes)]"},
wantSuggestions: []string{"did you mean: `__result0 > 100`", "valid references: `__result0`, `__result1`, `count()`, `sum(bytes)`"},
wantSuggestions: []string{"did you mean: `__result0 > 100`", "valid references are `__result0`, `__result1`, `count()`, `sum(bytes)`"},
},
{
name: "out-of-range __result_N index",
@@ -711,7 +711,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [__result_9]",
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__result`, `__result0`, `count()`"},
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references are `__result`, `__result0`, `count()`"},
},
{
name: "__result_1 out of range for single aggregation",
@@ -722,7 +722,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [__result_1]",
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__result`, `__result0`, `count()`"},
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references are `__result`, `__result0`, `count()`"},
},
{
name: "cascaded function calls",
@@ -733,7 +733,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [sum]",
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`"},
},
{
name: "function call with multiple args not in column map",
@@ -744,7 +744,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [sum]",
wantAdditional: []string{"Valid references are: [__result, __result0, sum(a)]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(a)`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(a)`"},
},
{
name: "unquoted string value treated as unknown identifier",
@@ -755,7 +755,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [xyz]",
wantAdditional: []string{"Valid references are: [__result, __result0, sum(bytes)]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(bytes)`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(bytes)`"},
},
})
}
@@ -1030,7 +1030,7 @@ func TestRewriteForMetrics(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [wrong_metric]",
wantAdditional: []string{"Valid references are: [__result, __result0, sum(cpu_usage)]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(cpu_usage)`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(cpu_usage)`"},
},
// --- Error: string literal (not allowed in HAVING) ---
{
@@ -1077,7 +1077,7 @@ func TestRewriteForMetrics(t *testing.T) {
wantErr: true,
wantErrMsg: "Invalid references in `Having` expression: [count]",
wantAdditional: []string{"Valid references are: [__result, __result0, sum(cpu_usage)]"},
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(cpu_usage)`"},
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(cpu_usage)`"},
},
}

View File

@@ -128,6 +128,7 @@ func NewModules(
}
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
return Modules{
OrgGetter: orgGetter,
@@ -142,8 +143,8 @@ func NewModules(
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),

View File

@@ -215,6 +215,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
)
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/meterreporter"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -349,10 +350,13 @@ func New(
// Initialize service account getter
serviceAccountGetter := implserviceaccount.NewGetter(implserviceaccount.NewStore(sqlstore))
authDomainGetter := implauthdomain.NewGetter(implauthdomain.NewStore(sqlstore))
// Build pre-delete callbacks from modules
onBeforeRoleDelete := []authz.OnBeforeRoleDelete{
userGetter.OnBeforeRoleDelete,
serviceAccountGetter.OnBeforeRoleDelete,
authDomainGetter.OnBeforeRoleDelete,
}
// Initialize authz
@@ -501,6 +505,7 @@ func New(
modules.LogsPipeline,
modules.InfraMonitoring,
querier,
authz,
}
// Initialize the stats aggregator (always-on, independent of whether reporting is enabled)

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