mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-26 18:10:27 +01:00
Compare commits
16 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8fb48fd72 | ||
|
|
c8c424c788 | ||
|
|
833aeda808 | ||
|
|
898209b5e5 | ||
|
|
9a97b3a623 | ||
|
|
672524adcc | ||
|
|
f4c3fedb03 | ||
|
|
caf75b097f | ||
|
|
5f649e0d9e | ||
|
|
c2f347d3f4 | ||
|
|
f3eda0956a | ||
|
|
d31255e717 | ||
|
|
34c90b1289 | ||
|
|
4bb32a69e5 | ||
|
|
8eb299e8fa | ||
|
|
5f39cd0038 |
@@ -3755,16 +3755,10 @@ components:
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- code
|
||||
- message
|
||||
- url
|
||||
- errors
|
||||
- retry
|
||||
- suggestions
|
||||
type: object
|
||||
ErrorsResponseerroradditional:
|
||||
properties:
|
||||
@@ -3774,17 +3768,11 @@ 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:
|
||||
@@ -5713,18 +5701,6 @@ 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:
|
||||
@@ -5824,10 +5800,7 @@ components:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
oneOf:
|
||||
- type: number
|
||||
- type: string
|
||||
value: {}
|
||||
type: object
|
||||
Querybuildertypesv5FunctionName:
|
||||
enum:
|
||||
@@ -5876,11 +5849,7 @@ components:
|
||||
properties:
|
||||
key:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
value:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
value: {}
|
||||
type: object
|
||||
Querybuildertypesv5LimitBy:
|
||||
properties:
|
||||
@@ -6203,29 +6172,39 @@ 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/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderTrace'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderLog'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderMetric'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeClickHouseSQL'
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeBuilder:
|
||||
properties:
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BuilderQuerySpec'
|
||||
spec: {}
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeBuilderLog:
|
||||
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'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
|
||||
properties:
|
||||
@@ -6233,8 +6212,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeFormula:
|
||||
properties:
|
||||
@@ -6242,8 +6219,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopePromQL:
|
||||
properties:
|
||||
@@ -6251,8 +6226,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5PromQuery'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeTraceOperator:
|
||||
properties:
|
||||
@@ -6260,8 +6233,6 @@ 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
|
||||
@@ -6465,17 +6436,7 @@ components:
|
||||
properties:
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5VariableType'
|
||||
value:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- items:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
type: array
|
||||
value: {}
|
||||
type: object
|
||||
Querybuildertypesv5VariableType:
|
||||
enum:
|
||||
|
||||
@@ -36,55 +36,6 @@ 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
|
||||
|
||||
@@ -143,10 +143,6 @@ 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)
|
||||
}
|
||||
@@ -374,7 +370,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
|
||||
for _, cb := range provider.onBeforeRoleDelete {
|
||||
if err := cb(ctx, orgID, id, role.Name); err != nil {
|
||||
if err := cb(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2178,21 +2178,16 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
message?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
suggestions: string[];
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export type ErrorsResponseretryjsonDTOAnyOf = {
|
||||
delay: TimeDurationDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ErrorsResponseretryjsonDTO = ErrorsResponseretryjsonDTOAnyOf | null;
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
delay?: TimeDurationDTO;
|
||||
}
|
||||
|
||||
export interface ErrorsJSONDTO {
|
||||
/**
|
||||
@@ -2202,24 +2197,24 @@ export interface ErrorsJSONDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
errors: ErrorsResponseerroradditionalDTO[];
|
||||
errors?: ErrorsResponseerroradditionalDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
retry: ErrorsResponseretryjsonDTO | null;
|
||||
retry?: ErrorsResponseretryjsonDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
suggestions: string[];
|
||||
suggestions?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
type?: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @type string
|
||||
*/
|
||||
url: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesOrgSessionContextDTO {
|
||||
@@ -3432,14 +3427,12 @@ export interface Querybuildertypesv5FilterDTO {
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5FunctionArgDTOValue = number | string;
|
||||
|
||||
export interface Querybuildertypesv5FunctionArgDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
value?: Querybuildertypesv5FunctionArgDTOValue;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5FunctionNameDTO {
|
||||
@@ -4272,21 +4265,26 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
|
||||
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
|
||||
}
|
||||
export type Querybuildertypesv5BuilderQuerySpecDTO =
|
||||
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO
|
||||
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO
|
||||
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO;
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeBuilderDTOType {
|
||||
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 Querybuildertypesv5QueryEnvelopeBuilderDTO {
|
||||
spec?: Querybuildertypesv5BuilderQuerySpecDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum builder_query
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
|
||||
export interface Querybuildertypesv5QueryEnvelopeBuilderTraceDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
|
||||
type?: Querybuildertypesv5QueryTypeDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryEnvelopeBuilderLogDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO;
|
||||
type?: Querybuildertypesv5QueryTypeDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryEnvelopeBuilderMetricDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO;
|
||||
type?: Querybuildertypesv5QueryTypeDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
@@ -4321,16 +4319,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeFormulaDTOType {
|
||||
builder_formula = 'builder_formula',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryEnvelopeFormulaDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderFormulaDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum builder_formula
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopeFormulaDTOType;
|
||||
type?: Querybuildertypesv5QueryTypeDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
@@ -4391,16 +4382,9 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType {
|
||||
builder_trace_operator = 'builder_trace_operator',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryEnvelopeTraceOperatorDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderTraceOperatorDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum builder_trace_operator
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType;
|
||||
type?: Querybuildertypesv5QueryTypeDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5PromQueryDTO {
|
||||
@@ -4427,16 +4411,9 @@ export interface Querybuildertypesv5PromQueryDTO {
|
||||
step?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopePromQLDTOType {
|
||||
promql = 'promql',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryEnvelopePromQLDTO {
|
||||
spec?: Querybuildertypesv5PromQueryDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum promql
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopePromQLDTOType;
|
||||
type?: Querybuildertypesv5QueryTypeDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5ClickHouseQueryDTO {
|
||||
@@ -4458,24 +4435,40 @@ export interface Querybuildertypesv5ClickHouseQueryDTO {
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType {
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
|
||||
spec?: Querybuildertypesv5ClickHouseQueryDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum clickhouse_sql
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType;
|
||||
type?: Querybuildertypesv5QueryTypeDTO;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5QueryEnvelopeDTO =
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderDTO
|
||||
| Querybuildertypesv5QueryEnvelopeFormulaDTO
|
||||
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
|
||||
| Querybuildertypesv5QueryEnvelopePromQLDTO
|
||||
| Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO;
|
||||
| (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;
|
||||
});
|
||||
|
||||
/**
|
||||
* Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.
|
||||
@@ -6812,11 +6805,9 @@ export interface MetricsexplorertypesInspectMetricsRequestDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5LabelDTOValue = string | number | boolean;
|
||||
|
||||
export interface Querybuildertypesv5LabelDTO {
|
||||
key?: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
value?: Querybuildertypesv5LabelDTOValue;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketDTO {
|
||||
@@ -7426,20 +7417,9 @@ 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?: Querybuildertypesv5VariableItemDTOValue;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5QueryRangeRequestDTOVariables = {
|
||||
@@ -7487,13 +7467,6 @@ 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;
|
||||
/**
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
}
|
||||
|
||||
.metrics-table-container {
|
||||
padding-bottom: 48px;
|
||||
.ant-table {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
@@ -84,12 +84,11 @@ export function KeyboardHotkeysProvider({
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const isCodeMirrorEditor = target.closest('.cm-editor') !== null;
|
||||
const isMonacoEditor = target.closest('.monaco-editor') !== null;
|
||||
const isCodeMirrorEditor =
|
||||
(target as HTMLElement).closest('.cm-editor') !== null;
|
||||
if (
|
||||
IGNORE_INPUTS.includes(target.tagName.toLowerCase()) ||
|
||||
isCodeMirrorEditor ||
|
||||
isMonacoEditor
|
||||
IGNORE_INPUTS.includes((target as HTMLElement).tagName.toLowerCase()) ||
|
||||
isCodeMirrorEditor
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
@@ -20,10 +18,20 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,15 +44,16 @@ function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
stepInterval,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
const signal = getBuilderQueries(spec.queries || [])[0]?.signal as
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
|
||||
|
||||
// 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.
|
||||
@@ -95,6 +104,10 @@ function ConfigPane({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
@@ -0,0 +1,122 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
@@ -7,7 +7,9 @@ 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';
|
||||
@@ -23,6 +25,13 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +47,10 @@ 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.
|
||||
@@ -60,7 +73,12 @@ function SectionSlot({
|
||||
.formatting?.unit;
|
||||
|
||||
return (
|
||||
<SettingsSection title={title} icon={<Icon size={15} />}>
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
// Open Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === 'visualization'}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
@@ -69,6 +87,10 @@ function SectionSlot({
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -26,13 +26,15 @@ 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-${title}`}
|
||||
data-testid={`config-section-${serializedTitle}`}
|
||||
onClick={(): void => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{icon && (
|
||||
|
||||
@@ -21,6 +21,7 @@ function renderConfigPane(
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
...overrides,
|
||||
@@ -56,6 +57,8 @@ 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')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('config-section-formatting-&-units'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.group {
|
||||
width: min(350px, 100%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -8,3 +8,11 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Select } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
|
||||
@@ -7,7 +8,11 @@ import styles from './ConfigSelect.module.scss';
|
||||
export interface ConfigSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps {
|
||||
@@ -38,17 +43,31 @@ function ConfigSelect({
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
virtual={false}
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.icon ? (
|
||||
options={items.map((item) => {
|
||||
const content = item.icon ? (
|
||||
<span className={styles.item}>
|
||||
<SegmentIcon name={item.icon} />
|
||||
{typeof item.icon === 'string' ? (
|
||||
<SegmentIcon name={item.icon as SegmentIconName} />
|
||||
) : (
|
||||
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
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ 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;
|
||||
|
||||
@@ -3,3 +3,14 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdPrefix {
|
||||
padding-right: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
@@ -11,6 +9,7 @@ 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';
|
||||
|
||||
@@ -77,16 +76,11 @@ function ChartAppearanceSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: 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 },
|
||||
});
|
||||
};
|
||||
stepInterval,
|
||||
}: SectionEditorProps<'chartAppearance'> & {
|
||||
/** Query step interval (seconds) for the span-gaps threshold floor. */
|
||||
stepInterval?: number;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.lineStyle && (
|
||||
@@ -146,16 +140,12 @@ function ChartAppearanceSection({
|
||||
)}
|
||||
|
||||
{controls.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>
|
||||
<DisconnectValuesField
|
||||
testId="panel-editor-v2-span-gaps"
|
||||
value={value?.spanGaps}
|
||||
stepInterval={stepInterval}
|
||||
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
@@ -0,0 +1,94 @@
|
||||
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}>></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;
|
||||
@@ -108,9 +108,24 @@ describe('ChartAppearanceSection', () => {
|
||||
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
|
||||
});
|
||||
|
||||
it('writes a span-gaps threshold and clears it when emptied', () => {
|
||||
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', () => {
|
||||
const onChange = jest.fn();
|
||||
const { rerender } = render(
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ spanGaps: true }}
|
||||
@@ -118,23 +133,103 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
|
||||
target: { value: '60' },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '60' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Threshold'));
|
||||
|
||||
rerender(
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '1m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the threshold as a duration string (not seconds)', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '60' } }}
|
||||
value={{ spanGaps: { fillLessThan: '1m' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
|
||||
target: { value: '' },
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,25 @@ 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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
|
||||
@@ -16,6 +17,12 @@ 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>
|
||||
@@ -23,8 +30,11 @@ function ThresholdValueField({
|
||||
data-testid={testId}
|
||||
type="number"
|
||||
placeholder="Value"
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
value={raw}
|
||||
onChange={(e): void => {
|
||||
setRaw(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} 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 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.
|
||||
* 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.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'visualization'>): JSX.Element {
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
signal,
|
||||
}: VisualizationSectionProps): 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>
|
||||
|
||||
@@ -4,6 +4,15 @@ 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();
|
||||
@@ -17,7 +26,12 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true, stacking: true, fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -35,7 +49,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -56,7 +73,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -74,7 +94,10 @@ describe('VisualizationSection', () => {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stackedBarChart: false,
|
||||
}}
|
||||
controls={{ stacking: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
stacking: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -92,7 +115,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: false }}
|
||||
controls={{ fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -101,4 +127,43 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,23 +8,35 @@ 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 {
|
||||
panelType: PANEL_TYPES;
|
||||
/** 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;
|
||||
/** 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. */
|
||||
@@ -41,12 +53,15 @@ interface PanelEditorQueryBuilderProps {
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelType,
|
||||
panelKind,
|
||||
signal,
|
||||
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();
|
||||
|
||||
@@ -74,13 +89,16 @@ 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(
|
||||
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
|
||||
[],
|
||||
() => getHiddenQueryBuilderFields(panelKind, signal),
|
||||
[panelKind, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
@@ -127,7 +145,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelType, filterConfigs, isDarkMode]);
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -18,6 +19,7 @@ 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';
|
||||
@@ -29,6 +31,7 @@ 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';
|
||||
@@ -65,6 +68,10 @@ 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,
|
||||
@@ -113,6 +120,9 @@ 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;
|
||||
@@ -146,6 +156,14 @@ 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.
|
||||
@@ -197,7 +215,8 @@ function PanelEditorContainer({
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelType={panelType}
|
||||
panelKind={fullKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
@@ -226,8 +245,11 @@ function PanelEditorContainer({
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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 };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,7 +3,10 @@ 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: { timePreference: true, stacking: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, stacking: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
|
||||
@@ -2,6 +2,7 @@ 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',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,6 +3,10 @@ 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 },
|
||||
|
||||
@@ -2,6 +2,8 @@ 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',
|
||||
@@ -12,6 +14,21 @@ 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,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [];
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ 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',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -2,6 +2,7 @@ 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',
|
||||
@@ -13,6 +14,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,7 +3,10 @@ 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: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -2,6 +2,7 @@ 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',
|
||||
@@ -13,6 +14,7 @@ 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,
|
||||
|
||||
@@ -4,7 +4,10 @@ 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: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'table' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -2,6 +2,7 @@ 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',
|
||||
@@ -13,6 +14,11 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, 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 } },
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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>>;
|
||||
@@ -1,9 +1,11 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -35,7 +37,12 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,8 +89,11 @@ export interface SectionControls {
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
|
||||
// stacking → stackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
|
||||
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
|
||||
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
|
||||
// 0 (TimeSeries).
|
||||
visualization: {
|
||||
switchPanelKind: boolean;
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
fillSpans?: boolean;
|
||||
@@ -128,7 +131,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', icon: Hash },
|
||||
formatting: { title: 'Formatting & Units', icon: Hash },
|
||||
axes: { title: 'Axes', icon: Ruler },
|
||||
legend: { title: 'Legend', icon: Layers },
|
||||
chartAppearance: { title: 'Chart appearance', icon: Palette },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import {
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesPrecisionOptionDTO,
|
||||
@@ -38,9 +39,10 @@ export function resolveDecimalPrecision(
|
||||
}
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
|
||||
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
|
||||
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
|
||||
* bare seconds number for back-compat.
|
||||
*/
|
||||
export function resolveSpanGaps(
|
||||
fillLessThan: string | undefined,
|
||||
@@ -48,8 +50,10 @@ export function resolveSpanGaps(
|
||||
if (!fillLessThan) {
|
||||
return true;
|
||||
}
|
||||
const parsed = Number(fillLessThan);
|
||||
return Number.isFinite(parsed) ? parsed : true;
|
||||
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)
|
||||
? rangeUtil.intervalToSeconds(fillLessThan)
|
||||
: Number(fillLessThan);
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : true;
|
||||
}
|
||||
|
||||
/** Legend position; missing/unknown falls back to `BOTTOM` (chart default, V1 parity). */
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
@@ -33,3 +36,21 @@ 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;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ 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',
|
||||
@@ -177,6 +179,49 @@ 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(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
FolderOutput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
@@ -23,7 +24,10 @@ import type { DashboardSection } from '../../../utils';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { useClonePanel } from '../hooks/useClonePanel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
@@ -37,6 +41,66 @@ 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`); */
|
||||
@@ -155,31 +219,15 @@ export function usePanelActionItems({
|
||||
});
|
||||
}
|
||||
|
||||
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 moveGroup: MenuItem[] =
|
||||
canMove && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
})
|
||||
: [];
|
||||
|
||||
const deleteGroup: MenuItem[] =
|
||||
canDelete && panelActions
|
||||
|
||||
@@ -26,13 +26,7 @@ describe('panelStatusFromError', () => {
|
||||
code: 'invalid_query',
|
||||
message: 'Query is invalid',
|
||||
url: 'https://docs/err',
|
||||
errors: [
|
||||
{ message: 'missing aggregation', suggestions: [] },
|
||||
{ message: 'bad filter', suggestions: [] },
|
||||
],
|
||||
retry: null,
|
||||
suggestions: [],
|
||||
type: '',
|
||||
errors: [{ message: 'missing aggregation' }, { message: 'bad filter' }],
|
||||
});
|
||||
|
||||
expect(panelStatusFromError(error)).toStrictEqual({
|
||||
@@ -54,15 +48,7 @@ describe('panelStatusFromError', () => {
|
||||
|
||||
it('omits docsUrl when the API error has no url', () => {
|
||||
const error = axiosErrorWith(
|
||||
{
|
||||
code: 'x',
|
||||
message: 'y',
|
||||
url: '',
|
||||
errors: [],
|
||||
retry: null,
|
||||
suggestions: [],
|
||||
type: '',
|
||||
},
|
||||
{ code: 'x', message: 'y', url: '', errors: [] },
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import styles from './PanelTypeSelectionModal.module.scss';
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (pluginKind: PanelKind) => void;
|
||||
onSelect: (panelKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModal({
|
||||
@@ -25,17 +25,17 @@ function PanelTypeSelectionModal({
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map((type) => (
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<Button
|
||||
key={type.pluginKind}
|
||||
key={panelKind}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${type.pluginKind}`}
|
||||
onClick={(): void => onSelect(type.pluginKind)}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
onClick={(): void => onSelect(panelKind)}
|
||||
>
|
||||
{type.icon}
|
||||
{type.label}
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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 },
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
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} /> },
|
||||
];
|
||||
@@ -1,7 +1,16 @@
|
||||
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 {
|
||||
pluginKind: PanelKind;
|
||||
panelKind: PanelKind;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
/** Icon component — the consumer renders it and controls size/color/etc. */
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,10 @@ describe('useClonePanel', () => {
|
||||
op: 'add',
|
||||
path: '/spec/layouts/0/spec/items/-',
|
||||
value: {
|
||||
// Same dimensions as the source panel (p1: 8x5).
|
||||
// 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.
|
||||
x: 0,
|
||||
// Bottom of the section: max(y + height) over existing items = 5.
|
||||
y: 5,
|
||||
width: 8,
|
||||
height: 5,
|
||||
@@ -75,6 +76,27 @@ 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() }));
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
findFreeSlot,
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
@@ -20,8 +24,9 @@ export interface ClonePanelArgs {
|
||||
|
||||
/**
|
||||
* Duplicates a panel: deep-copies the source spec under a fresh id and drops a
|
||||
* same-size grid item at the bottom of the section, as one atomic patch. Mirrors
|
||||
* V1's clone (verbatim spec copy, no rename).
|
||||
* 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).
|
||||
*/
|
||||
export function useClonePanel({
|
||||
sections,
|
||||
@@ -38,10 +43,7 @@ export function useClonePanel({
|
||||
}
|
||||
|
||||
const newPanelId = uuid();
|
||||
const nextY = section.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const { x, y } = findFreeSlot(section.items, source.width);
|
||||
|
||||
const clone = patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
@@ -50,8 +52,8 @@ export function useClonePanel({
|
||||
panel: cloneDeep(source.panel),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
x,
|
||||
y,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
content: { $ref: panelRef(newPanelId) },
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('createPanelOps', () => {
|
||||
expect(value.y).toBe(6);
|
||||
});
|
||||
|
||||
it('falls back to the last section when no index is requested', () => {
|
||||
it('falls back to the root (first) 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/1/spec/items/-');
|
||||
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
|
||||
});
|
||||
|
||||
it('falls back to the last section when the requested index is out of range', () => {
|
||||
const layouts = [section([])];
|
||||
it('falls back to the root (first) section when the requested index is out of range', () => {
|
||||
const layouts = [section([item(0, 6)]), section([])];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
|
||||
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
|
||||
});
|
||||
|
||||
@@ -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 the "Add panel" trigger); falls back to the last. */
|
||||
/** Preferred section (from a section's "Add panel" trigger); falls back to the root (first) section. */
|
||||
layoutIndex: number | undefined;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -132,13 +132,16 @@ 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.
|
||||
*/
|
||||
function findFreeSlot(
|
||||
items: DashboardGridItemDTO[],
|
||||
export function findFreeSlot(
|
||||
items: PlacedItem[],
|
||||
width: number,
|
||||
): { x: number; y: number } {
|
||||
const w = Math.min(width, GRID_COLS);
|
||||
@@ -163,8 +166,8 @@ function findFreeSlot(
|
||||
|
||||
/**
|
||||
* Ops to persist a brand-new panel (editor save path): resolve the target
|
||||
* section (requested index if valid, else last, else a freshly-created one) and
|
||||
* place the panel via `findFreeSlot`.
|
||||
* section (requested index if valid, else the root/first section, else a
|
||||
* freshly-created one) and place the panel via `findFreeSlot`.
|
||||
*/
|
||||
export function createPanelOps({
|
||||
layouts,
|
||||
@@ -174,14 +177,17 @@ export function createPanelOps({
|
||||
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
|
||||
const requested =
|
||||
layoutIndex !== undefined && layouts[layoutIndex] !== undefined
|
||||
? layoutIndex
|
||||
: layouts.length - 1;
|
||||
|
||||
let targetIndex = requested;
|
||||
let items: DashboardGridItemDTO[] = layouts[requested]?.spec.items ?? [];
|
||||
if (targetIndex < 0) {
|
||||
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 {
|
||||
// No sections yet — create an untitled one and target it.
|
||||
ops.push(addSectionOp(''));
|
||||
targetIndex = 0;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5ClickHouseQueryDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5PromQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -71,26 +66,19 @@ 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: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
type: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
spec: plugin.spec,
|
||||
},
|
||||
];
|
||||
case 'signoz/PromQLQuery':
|
||||
return [
|
||||
{
|
||||
type: Querybuildertypesv5QueryEnvelopePromQLDTOType.promql,
|
||||
spec: plugin.spec as Querybuildertypesv5PromQueryDTO,
|
||||
},
|
||||
];
|
||||
return [{ type: Querybuildertypesv5QueryTypeDTO.promql, spec: plugin.spec }];
|
||||
case 'signoz/ClickHouseSQL':
|
||||
return [
|
||||
{
|
||||
type: Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql,
|
||||
spec: plugin.spec as Querybuildertypesv5ClickHouseQueryDTO,
|
||||
type: Querybuildertypesv5QueryTypeDTO.clickhouse_sql,
|
||||
spec: plugin.spec,
|
||||
},
|
||||
];
|
||||
case 'signoz/Formula':
|
||||
@@ -146,22 +134,14 @@ function withBarStepInterval(
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
|
||||
return envelope;
|
||||
}
|
||||
if (envelope.spec?.stepInterval) {
|
||||
const spec = envelope.spec as QuerySpecView;
|
||||
if (spec.stepInterval) {
|
||||
return envelope;
|
||||
}
|
||||
return {
|
||||
...envelope,
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
stepInterval,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
};
|
||||
return { ...envelope, spec: { ...spec, stepInterval } };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,19 +154,12 @@ function withPagination(
|
||||
{ offset, limit }: { offset: number; limit: number },
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
|
||||
return envelope;
|
||||
}
|
||||
return {
|
||||
...envelope,
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
offset,
|
||||
limit,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
spec: { ...(envelope.spec as Record<string, unknown>), offset, limit },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -265,8 +238,7 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
|
||||
const metricsSpecs = envelopes
|
||||
.filter(
|
||||
(envelope) =>
|
||||
envelope.type ===
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
)
|
||||
.map((envelope) => envelope.spec as QuerySpecView)
|
||||
.filter((spec) => spec.signal === 'metrics');
|
||||
|
||||
@@ -7,9 +7,7 @@ import type {
|
||||
import {
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
@@ -48,24 +46,17 @@ const toGeneratedEnvelopes = (
|
||||
|
||||
const isBuilderQueryEnvelope = (
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): boolean =>
|
||||
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
|
||||
): boolean => envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query;
|
||||
|
||||
export function deriveQueryType(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
): EQueryType {
|
||||
if (
|
||||
envelopes.some(
|
||||
(e) => e.type === Querybuildertypesv5QueryEnvelopePromQLDTOType.promql,
|
||||
)
|
||||
) {
|
||||
if (envelopes.some((e) => e.type === Querybuildertypesv5QueryTypeDTO.promql)) {
|
||||
return EQueryType.PROM;
|
||||
}
|
||||
if (
|
||||
envelopes.some(
|
||||
(e) =>
|
||||
e.type ===
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql,
|
||||
(e) => e.type === Querybuildertypesv5QueryTypeDTO.clickhouse_sql,
|
||||
)
|
||||
) {
|
||||
return EQueryType.CLICKHOUSE;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type {
|
||||
Querybuildertypesv5ColumnDescriptorDTO,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5QueryTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelTable, PanelTableColumn } from './types';
|
||||
|
||||
@@ -28,15 +26,15 @@ export function extractAggregationsPerQuery(
|
||||
): AggregationsPerQuery {
|
||||
const perQuery: AggregationsPerQuery = {};
|
||||
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
|
||||
return;
|
||||
}
|
||||
const spec = envelope.spec;
|
||||
const spec = envelope.spec as {
|
||||
name?: string;
|
||||
aggregations?: AggregationView[];
|
||||
};
|
||||
if (spec?.name && spec.aggregations) {
|
||||
perQuery[spec.name] = spec.aggregations as AggregationView[];
|
||||
perQuery[spec.name] = spec.aggregations;
|
||||
}
|
||||
});
|
||||
return perQuery;
|
||||
@@ -54,14 +52,13 @@ export function extractClickhouseQueryNames(
|
||||
): Set<string> {
|
||||
const names = new Set<string>();
|
||||
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql
|
||||
) {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.clickhouse_sql) {
|
||||
return;
|
||||
}
|
||||
if (envelope.spec?.name) {
|
||||
names.add(envelope.spec.name);
|
||||
const spec = (envelope as Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO)
|
||||
.spec;
|
||||
if (spec?.name) {
|
||||
names.add(spec.name);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
|
||||
@@ -137,13 +137,3 @@ 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
2
go.mod
@@ -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
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
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
|
||||
|
||||
@@ -66,9 +66,6 @@ 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)
|
||||
|
||||
@@ -95,7 +92,7 @@ type AuthZ interface {
|
||||
}
|
||||
|
||||
// OnBeforeRoleDelete is a callback invoked before a role is deleted.
|
||||
type OnBeforeRoleDelete func(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
|
||||
type OnBeforeRoleDelete func(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
type Handler interface {
|
||||
Create(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -95,15 +95,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -75,12 +75,6 @@ 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{
|
||||
@@ -343,16 +337,6 @@ 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...)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors" //nolint:depguard
|
||||
"testing"
|
||||
"time"
|
||||
@@ -85,7 +84,7 @@ func TestWithSuggestiveAdditional(t *testing.T) {
|
||||
assert.Equal(t, []responseerroradditional{
|
||||
{Message: "field `filed` not found", Suggestions: []string{"did you mean: `field`"}},
|
||||
}, j.Errors)
|
||||
assert.Empty(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
assert.Nil(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
}
|
||||
|
||||
func TestWithRetryAfter(t *testing.T) {
|
||||
@@ -107,12 +106,7 @@ 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)
|
||||
// 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)
|
||||
assert.Equal(t, []responseerroradditional{{Message: "hint1"}, {Message: "hint2"}}, j.Errors)
|
||||
|
||||
// InvalidInput auto-applies the after_fix policy via NewInvalidInputf — but
|
||||
// New (bare constructor) does not. The retry block should reflect that.
|
||||
@@ -163,16 +157,9 @@ func TestAsJSONRetryBlock(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.
|
||||
func TestAsJSONOptionalFieldsOmittedWhenEmpty(t *testing.T) {
|
||||
j := AsJSON(New(TypeInternal, MustNewCode("boom"), "boom"))
|
||||
|
||||
assert.NotNil(t, j.Suggestions)
|
||||
assert.Empty(t, j.Suggestions)
|
||||
assert.NotNil(t, j.Errors)
|
||||
assert.Empty(t, j.Errors)
|
||||
assert.Nil(t, j.Suggestions, "no suggestions set => Suggestions must be nil so json omitempty drops it")
|
||||
}
|
||||
|
||||
func TestWithStacktrace(t *testing.T) {
|
||||
@@ -186,16 +173,3 @@ 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))
|
||||
}
|
||||
|
||||
@@ -7,29 +7,32 @@ import (
|
||||
)
|
||||
|
||||
type JSON struct {
|
||||
Type string `json:"type" required:"true"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
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"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Errors []responseerroradditional `json:"errors,omitempty"`
|
||||
Retry *responseretryjson `json:"retry,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
type responseretryjson struct {
|
||||
Delay time.Duration `json:"delay" required:"true" nullable:"false"`
|
||||
Delay time.Duration `json:"delay"`
|
||||
}
|
||||
|
||||
type responseerroradditional struct {
|
||||
Message string `json:"message" required:"true"`
|
||||
Suggestions []string `json:"suggestions" required:"true" nullable:"false"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
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 := responseAdditionals(a)
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
|
||||
var retry *responseretryjson
|
||||
if r := retryOf(cause); r != nil {
|
||||
@@ -43,7 +46,7 @@ func AsJSON(cause error) *JSON {
|
||||
Url: u,
|
||||
Errors: rea,
|
||||
Retry: retry,
|
||||
Suggestions: nonNilStrings(suggestionsOf(cause)),
|
||||
Suggestions: suggestionsOf(cause),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +54,10 @@ 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 := responseAdditionals(a)
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
|
||||
errors, err := json.Marshal(rea)
|
||||
if err != nil {
|
||||
@@ -69,20 +75,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -5,18 +5,6 @@ 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
|
||||
@@ -25,18 +13,17 @@ const (
|
||||
maxValidReferences = 20
|
||||
)
|
||||
|
||||
// NewSuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// SuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// when a close match at least typoSuggestionThreshold similar exists) followed
|
||||
// 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 {
|
||||
// by the valid-references list.
|
||||
func SuggestionsOnLevenshteinDistance(invalidInput string, validInputs []string) []string {
|
||||
suggestions := make([]string, 0, 2)
|
||||
|
||||
if match, ok := ClosestLevenshteinMatch(invalidInput, validInputs); ok {
|
||||
suggestions = append(suggestions, didYouMean(match))
|
||||
}
|
||||
|
||||
if refs := NewValidReferences(noun, validInputs...); refs != "" {
|
||||
if refs := ValidReferences(validInputs...); refs != "" {
|
||||
suggestions = append(suggestions, refs)
|
||||
}
|
||||
|
||||
@@ -65,10 +52,10 @@ func ClosestLevenshteinMatch(input string, candidates []string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// NewSuggestionsFromFunc formats the string produce returns as a one-element
|
||||
// SuggestionsFromFunc 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 NewSuggestionsFromFunc(produce func() string) []string {
|
||||
func SuggestionsFromFunc(produce func() string) []string {
|
||||
s := produce()
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -77,12 +64,12 @@ func NewSuggestionsFromFunc(produce func() string) []string {
|
||||
return []string{didYouMean(s)}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -110,7 +97,7 @@ func NewValidReferences[T any](noun string, values ...T) string {
|
||||
quoted[i] = "`" + r + "`"
|
||||
}
|
||||
|
||||
out := "valid " + noun + " are " + strings.Join(quoted, ", ")
|
||||
out := "valid references: " + strings.Join(quoted, ", ")
|
||||
if truncated > 0 {
|
||||
out += fmt.Sprintf(" (+%d more)", truncated)
|
||||
}
|
||||
|
||||
@@ -6,28 +6,26 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
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))
|
||||
func TestValidReferences(t *testing.T) {
|
||||
// An empty set returns "" so callers don't surface a bare "valid references: ".
|
||||
assert.Equal(t, "", ValidReferences[string]())
|
||||
|
||||
// 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"))
|
||||
assert.Equal(t, "valid references: `a`, `b`", ValidReferences("a", "b"))
|
||||
}
|
||||
|
||||
func TestNewSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid <noun> are").
|
||||
assert.Empty(t, NewSuggestionsOnLevenshteinDistance("foo", NounFields, nil))
|
||||
func TestSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid references: ").
|
||||
assert.Empty(t, SuggestionsOnLevenshteinDistance("foo", nil))
|
||||
|
||||
// Close match => did-you-mean plus the valid-references list.
|
||||
assert.Equal(t,
|
||||
[]string{"did you mean: `name`", "valid fields are `name`, `color`"},
|
||||
NewSuggestionsOnLevenshteinDistance("nam", NounFields, []string{"name", "color"}),
|
||||
[]string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("nam", []string{"name", "color"}),
|
||||
)
|
||||
|
||||
// No close match => valid-references list only.
|
||||
assert.Equal(t,
|
||||
[]string{"valid fields are `name`, `color`"},
|
||||
NewSuggestionsOnLevenshteinDistance("zzzzz", NounFields, []string{"name", "color"}),
|
||||
[]string{"valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("zzzzz", []string{"name", "color"}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, jsonschema.JSONFieldNames(obj))...)
|
||||
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, JSONFieldNames(obj))...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,37 @@ 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 {
|
||||
|
||||
@@ -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 fields are `name`, `color`"},
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "WithContext",
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true), WithUnknownFieldContext("widget spec")},
|
||||
message: `unknown field "shape" in widget spec`,
|
||||
suggestions: []string{"valid fields are `name`, `color`"},
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "NearMatch",
|
||||
body: `{"nam":"x"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "nam"`,
|
||||
suggestions: []string{"did you mean: `name`", "valid fields are `name`, `color`"},
|
||||
suggestions: []string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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","errors":[],"retry":null,"suggestions":[]}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists"}}`),
|
||||
},
|
||||
"/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","suggestions":[]},{"message":"a2","suggestions":[]}],"retry":null,"suggestions":[]}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1"},{"message":"a2"}]}}`),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ func TestErrorRetryAfterHeader(t *testing.T) {
|
||||
name: "BareErrorNoHeaderNoRetryBlock",
|
||||
err: errors.New(errors.TypeInternal, errors.MustNewCode("boom"), "boom"),
|
||||
wantRetryAfter: "",
|
||||
wantBodyContains: `"retry":null`,
|
||||
wantBodyNotContains: `"delay"`,
|
||||
wantBodyContains: `"code":"boom"`,
|
||||
wantBodyNotContains: `"retry"`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -44,7 +44,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -13,18 +12,13 @@ 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, authz authz.AuthZ) authdomain.Module {
|
||||
return &module{store: store, authNs: authNs, authz: authz}
|
||||
func NewModule(store authtypes.AuthDomainStore, authNs map[authtypes.AuthNProvider]authn.AuthN) authdomain.Module {
|
||||
return &module{store: store, authNs: authNs}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -56,10 +50,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -84,13 +74,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -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, _ string) error {
|
||||
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
|
||||
serviceAccounts, err := getter.store.GetServiceAccountsByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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, roleName string) error
|
||||
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -30,10 +29,9 @@ 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, authz authz.AuthZ) 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) session.Module {
|
||||
return &module{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
|
||||
authNs: authNs,
|
||||
@@ -42,7 +40,6 @@ func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.A
|
||||
authDomain: authDomain,
|
||||
tokenizer: tokenizer,
|
||||
orgGetter: orgGetter,
|
||||
authz: authz,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,23 +143,15 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
|
||||
}
|
||||
|
||||
roleMapping := authDomain.AuthDomainConfig().RoleMapping
|
||||
|
||||
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)
|
||||
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
|
||||
signozManagedRole := authtypes.MustGetSigNozManagedRoleFromExistingRole(role)
|
||||
|
||||
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(roleNames))
|
||||
newUser, err = module.userSetter.GetOrCreateUser(ctx, newUser, user.WithRoleNames([]string{signozManagedRole}))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -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, _ string) error {
|
||||
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
|
||||
users, err := module.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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, roleName string) error
|
||||
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
|
||||
|
||||
// VerifyResetPasswordToken checks if a reset password token exists and is not expired.
|
||||
VerifyResetPasswordToken(ctx context.Context, token string) error
|
||||
|
||||
@@ -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.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
return "", nil, wrappedErr
|
||||
} else {
|
||||
for _, key := range keysForField {
|
||||
|
||||
@@ -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.NewSuggestionsFromFunc(func() string {
|
||||
suggestions = errors.SuggestionsFromFunc(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.NewValidReferences(errors.NounReferences, validKeys...))
|
||||
suggestions = append(suggestions, errors.ValidReferences(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.NewSuggestionsFromFunc(func() string {
|
||||
suggestions := errors.SuggestionsFromFunc(func() string {
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"did you mean: `total > 100`", "valid references: `__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 are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result0`, `__result1`, `count()`, `sum(bytes)`"},
|
||||
wantSuggestions: []string{"did you mean: `__result0 > 100`", "valid references: `__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 are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__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 are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__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 are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result`, `__result0`, `sum(a)`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result`, `__result0`, `sum(bytes)`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
wantSuggestions: []string{"valid references: `__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 are `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ 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,
|
||||
@@ -143,8 +142,8 @@ func NewModules(
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz),
|
||||
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
|
||||
|
||||
@@ -215,7 +215,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
|
||||
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ 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"
|
||||
@@ -350,13 +349,10 @@ 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
|
||||
@@ -505,7 +501,6 @@ 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
Reference in New Issue
Block a user