Compare commits

..

1 Commits

Author SHA1 Message Date
Tushar Vats
c8c050053c fix: draft 2026-06-11 01:21:25 +05:30
101 changed files with 2135 additions and 6331 deletions

View File

@@ -2436,6 +2436,13 @@ components:
url:
type: string
type: object
DashboardPanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
@@ -2489,17 +2496,10 @@ components:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesBuilderQuerySpec:
discriminator:
mapping:
logs: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
metrics: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
traces: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
propertyName: signal
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type: object
DashboardtypesComparisonOperator:
enum:
- above
@@ -2563,12 +2563,13 @@ components:
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
type: object
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
$ref: '#/components/schemas/CommonDisplay'
duration:
type: string
layouts:
items:
$ref: '#/components/schemas/DashboardtypesLayout'
nullable: true
type: array
links:
items:
@@ -2577,6 +2578,7 @@ components:
panels:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesPanel'
nullable: true
type: object
refreshInterval:
type: string
@@ -2584,20 +2586,10 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
required:
- display
- variables
- panels
- layouts
type: object
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
type: object
DashboardtypesDatasourcePluginKind:
enum:
- signoz/Datasource
@@ -2624,15 +2616,6 @@ components:
plugin:
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
type: object
DashboardtypesDisplay:
properties:
description:
type: string
name:
type: string
required:
- name
type: object
DashboardtypesDynamicVariableSpec:
properties:
name:
@@ -2673,7 +2656,7 @@ components:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
$ref: '#/components/schemas/TagtypesPostableTag'
nullable: true
type: array
updatedAt:
@@ -2750,13 +2733,8 @@ components:
- path
type: object
DashboardtypesLayout:
discriminator:
mapping:
Grid: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
type: object
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec:
properties:
kind:
@@ -2796,11 +2774,6 @@ components:
- solid
- dashed
type: string
DashboardtypesListOrder:
enum:
- asc
- desc
type: string
DashboardtypesListPanelSpec:
properties:
selectFields:
@@ -2808,12 +2781,6 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
type: object
DashboardtypesListSort:
enum:
- updated_at
- created_at
- name
type: string
DashboardtypesListVariableSpec:
properties:
allowAllValue:
@@ -2827,7 +2794,7 @@ components:
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
plugin:
@@ -2835,136 +2802,6 @@ components:
sort:
nullable: true
type: string
required:
- display
type: object
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
total:
format: int64
type: integer
required:
- dashboards
- total
- tags
type: object
DashboardtypesListableDashboardV2:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
total:
format: int64
type: integer
required:
- dashboards
- total
- tags
type: object
DashboardtypesListedDashboardForUserV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
pinned:
type: boolean
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2Spec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
- pinned
type: object
DashboardtypesListedDashboardV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2Spec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
@@ -2984,9 +2821,6 @@ components:
$ref: '#/components/schemas/DashboardtypesPanelKind'
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelFormatting:
properties:
@@ -3000,16 +2834,6 @@ components:
- Panel
type: string
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
@@ -3018,7 +2842,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
- signoz/TimeSeriesPanel
@@ -3116,7 +2939,7 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
$ref: '#/components/schemas/DashboardPanelDisplay'
links:
items:
$ref: '#/components/schemas/DashboardLink'
@@ -3126,12 +2949,7 @@ components:
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
nullable: true
type: array
required:
- display
- plugin
- queries
type: object
DashboardtypesPatchOp:
enum:
@@ -3200,20 +3018,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
signoz/Formula: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
signoz/PromQLQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
signoz/TraceOperator: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -3221,7 +3027,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
type: object
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
@@ -3309,8 +3114,6 @@ components:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
required:
- plugin
type: object
DashboardtypesQueryVariableSpec:
properties:
@@ -3478,15 +3281,9 @@ components:
type: boolean
type: object
DashboardtypesVariable:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
@@ -3512,17 +3309,10 @@ components:
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
signoz/CustomVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec'
signoz/DynamicVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec'
signoz/QueryVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec'
type: object
DashboardtypesVariablePluginKind:
enum:
- signoz/DynamicVariable
@@ -5725,15 +5515,11 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
enum:
- logs
type: string
$ref: '#/components/schemas/TelemetrytypesSignal'
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation:
properties:
@@ -5780,15 +5566,11 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
enum:
- metrics
type: string
$ref: '#/components/schemas/TelemetrytypesSignal'
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation:
properties:
@@ -5835,15 +5617,11 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
enum:
- traces
type: string
$ref: '#/components/schemas/TelemetrytypesSignal'
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderTraceOperator:
properties:
@@ -7284,16 +7062,6 @@ components:
required:
- references
type: object
TagtypesGettableTag:
properties:
key:
type: string
value:
type: string
required:
- key
- value
type: object
TagtypesPostableTag:
properties:
key:
@@ -13329,82 +13097,6 @@ paths:
tags:
- preferences
/api/v2/dashboards:
get:
deprecated: false
description: Returns a page of v2-shape dashboards for the org. This is the
pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2
for the personalized, pin-aware list. Supports a filter DSL (`query`), sort
(`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based
pagination (`limit`/`offset`).
operationId: ListDashboardsV2
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: sort
schema:
$ref: '#/components/schemas/DashboardtypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/DashboardtypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboards (v2)
tags:
- dashboard
post:
deprecated: false
description: This endpoint creates a dashboard in the v2 format that follows
@@ -13463,62 +13155,6 @@ paths:
tags:
- dashboard
/api/v2/dashboards/{id}:
delete:
deprecated: false
description: This endpoint deletes a v2-shape dashboard along with its tag relations.
Locked dashboards are rejected.
operationId: DeleteDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Delete dashboard (v2)
tags:
- dashboard
get:
deprecated: false
description: This endpoint returns a v2-shape dashboard.
@@ -20741,196 +20377,6 @@ paths:
summary: Update my user v2
tags:
- users
/api/v2/users/me/dashboards:
get:
deprecated: false
description: 'Same as ListDashboardsV2 but personalized for the calling user:
each dashboard carries the caller''s `pinned` state, and pinned dashboards
float to the top of the requested ordering. Supports the same filter DSL,
sort, order, and pagination.'
operationId: ListDashboardsForUserV2
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: sort
schema:
$ref: '#/components/schemas/DashboardtypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/DashboardtypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardForUserV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboards for the current user (v2)
tags:
- dashboard
/api/v2/users/me/dashboards/{id}/pins:
delete:
deprecated: false
description: Removes the pin for the calling user. Idempotent — unpinning a
dashboard that wasn't pinned still returns 204.
operationId: UnpinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Unpin a dashboard for the current user (v2)
tags:
- dashboard
put:
deprecated: false
description: Pins the dashboard for the calling user. A user can pin at most
10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned
dashboard is a no-op success.
operationId: PinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Pin a dashboard for the current user (v2)
tags:
- dashboard
/api/v2/users/me/factor_password:
put:
deprecated: false

View File

@@ -333,50 +333,6 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
}
```
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.
```go
// On the parent: expose the oneOf variants...
func (Plugin) JSONSchemaOneOf() []any {
return []any{FooVariant{}}
}
// ...and tag that same oneOf with the discriminator marker.
func (Plugin) PrepareJSONSchema(s *jsonschema.Schema) error {
if s.ExtraProperties == nil {
s.ExtraProperties = map[string]any{}
}
s.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": map[string]string{
"signoz/Foo": "#/components/schemas/FooVariant",
},
}
return nil
}
```
Each variant must declare the discriminator property (`kind`) and mark it `required`.
This produces the following in the generated OpenAPI spec:
```yaml
Plugin:
discriminator:
propertyName: kind
mapping:
signoz/Foo: '#/components/schemas/FooVariant'
oneOf:
- $ref: '#/components/schemas/FooVariant'
type: object
```
Note the discriminator property lives in the variants, not on the parent — the parent is only the union.
## What should I remember?

View File

@@ -229,39 +229,10 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
return module.pkgDashboardModule.ListV2(ctx, orgID, params)
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
return module.pkgDashboardModule.ListForUserV2(ctx, orgID, userID, params)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

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

View File

@@ -185,7 +185,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -16,11 +16,10 @@ func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
}
func (f *formatter) JSONExtractString(column, path string) []byte {
ops := f.convertJSONPathToPostgres(path)
if len(ops) == 0 {
return f.bunf.AppendIdent(nil, column)
}
return append(f.TextToJsonColumn(column), ops...)
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgres(path)...)
return sql
}
func (f *formatter) JSONType(column, path string) []byte {

View File

@@ -18,19 +18,19 @@ func TestJSONExtractString(t *testing.T) {
name: "simple path",
column: "data",
path: "$.field",
expected: `"data"::jsonb->>'field'`,
expected: `"data"->>'field'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.name",
expected: `"metadata"::jsonb->'user'->>'name'`,
expected: `"metadata"->'user'->>'name'`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.level1.level2.level3",
expected: `"json_col"::jsonb->'level1'->'level2'->>'level3'`,
expected: `"json_col"->'level1'->'level2'->>'level3'`,
},
{
name: "root path",

View File

@@ -26,7 +26,6 @@ import type {
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -36,17 +35,11 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
@@ -648,103 +641,6 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)
@@ -828,85 +724,6 @@ export const useCreateDashboardV2 = <
> => {
return useMutation(getCreateDashboardV2MutationOptions(options));
};
/**
* This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.
* @summary Delete dashboard (v2)
*/
export const deleteDashboardV2 = (
{ id }: DeleteDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteDashboardV2>>,
{ pathParams: DeleteDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardV2>>
>;
export type DeleteDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard (v2)
*/
export const useDeleteDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
return useMutation(getDeleteDashboardV2MutationOptions(options));
};
/**
* This endpoint returns a v2-shape dashboard.
* @summary Get dashboard (v2)
@@ -1364,260 +1181,3 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)
*/
export const listDashboardsForUserV2 = (
params?: ListDashboardsForUserV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsForUserV2200>({
url: `/api/v2/users/me/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsForUserV2QueryKey = (
params?: ListDashboardsForUserV2Params,
) => {
return [`/api/v2/users/me/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsForUserV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListDashboardsForUserV2QueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
> = ({ signal }) => listDashboardsForUserV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsForUserV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
>;
export type ListDashboardsForUserV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards for the current user (v2)
*/
export function useListDashboardsForUserV2<
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsForUserV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards for the current user (v2)
*/
export const invalidateListDashboardsForUserV2 = async (
queryClient: QueryClient,
params?: ListDashboardsForUserV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsForUserV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
* @summary Unpin a dashboard for the current user (v2)
*/
export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'DELETE',
signal,
});
};
export const getUnpinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unpinDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unpinDashboardV2>>,
{ pathParams: UnpinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unpinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnpinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unpinDashboardV2>>
>;
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unpin a dashboard for the current user (v2)
*/
export const useUnpinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnpinDashboardV2MutationOptions(options));
};
/**
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
* @summary Pin a dashboard for the current user (v2)
*/
export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'PUT',
signal,
});
};
export const getPinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['pinDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof pinDashboardV2>>,
{ pathParams: PinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return pinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type PinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof pinDashboardV2>>
>;
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Pin a dashboard for the current user (v2)
*/
export const usePinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
return useMutation(getPinDashboardV2MutationOptions(options));
};

View File

@@ -3156,6 +3156,17 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3484,9 +3495,6 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
unit?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
}
@@ -3542,11 +3550,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum logs
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal;
signal?: TelemetrytypesSignalDTO;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3612,9 +3616,6 @@ export interface Querybuildertypesv5MetricAggregationDTO {
timeAggregation?: MetrictypesTimeAggregationDTO;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal {
metrics = 'metrics',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO {
/**
* @type array
@@ -3667,11 +3668,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum metrics
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal;
signal?: TelemetrytypesSignalDTO;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3687,9 +3684,6 @@ export interface Querybuildertypesv5TraceAggregationDTO {
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO {
/**
* @type array
@@ -3742,11 +3736,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum traces
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal;
signal?: TelemetrytypesSignalDTO;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3881,17 +3871,6 @@ export type DashboardtypesDashboardSpecDTODatasources = {
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
}
export interface DashboardtypesDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
}
@@ -4440,36 +4419,42 @@ export interface DashboardtypesQuerySpecDTO {
* @type string
*/
name?: string;
plugin: DashboardtypesQueryPluginDTO;
plugin?: DashboardtypesQueryPluginDTO;
}
export interface DashboardtypesQueryDTO {
kind: Querybuildertypesv5RequestTypeDTO;
spec: DashboardtypesQuerySpecDTO;
kind?: Querybuildertypesv5RequestTypeDTO;
spec?: DashboardtypesQuerySpecDTO;
}
export interface DashboardtypesPanelSpecDTO {
display: DashboardtypesDisplayDTO;
display?: DashboardPanelDisplayDTO;
/**
* @type array
*/
links?: DashboardLinkDTO[];
plugin: DashboardtypesPanelPluginDTO;
plugin?: DashboardtypesPanelPluginDTO;
/**
* @type array,null
* @type array
*/
queries: DashboardtypesQueryDTO[] | null;
queries?: DashboardtypesQueryDTO[];
}
export interface DashboardtypesPanelDTO {
kind: DashboardtypesPanelKindDTO;
spec: DashboardtypesPanelSpecDTO;
kind?: DashboardtypesPanelKindDTO;
spec?: DashboardtypesPanelSpecDTO;
}
export type DashboardtypesDashboardSpecDTOPanels = {
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
[key: string]: DashboardtypesPanelDTO;
};
/**
* @nullable
*/
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
@@ -4566,7 +4551,7 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
display?: VariableDisplayDTO;
/**
* @type string
*/
@@ -4608,23 +4593,23 @@ export interface DashboardtypesDashboardSpecDTO {
* @type object
*/
datasources?: DashboardtypesDashboardSpecDTODatasources;
display: DashboardtypesDisplayDTO;
display?: CommonDisplayDTO;
/**
* @type string
*/
duration?: string;
/**
* @type array
* @type array,null
*/
layouts: DashboardtypesLayoutDTO[];
layouts?: DashboardtypesLayoutDTO[] | null;
/**
* @type array
*/
links?: DashboardLinkDTO[];
/**
* @type object
* @type object,null
*/
panels: DashboardtypesDashboardSpecDTOPanels;
panels?: DashboardtypesDashboardSpecDTOPanels;
/**
* @type string
*/
@@ -4632,13 +4617,13 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
variables: DashboardtypesVariableDTO[];
variables?: DashboardtypesVariableDTO[];
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
export interface TagtypesGettableTagDTO {
export interface TagtypesPostableTagDTO {
/**
* @type string
*/
@@ -4688,7 +4673,7 @@ export interface DashboardtypesGettableDashboardV2DTO {
/**
* @type array,null
*/
tags: TagtypesGettableTagDTO[] | null;
tags: TagtypesPostableTagDTO[] | null;
/**
* @type string
* @format date-time
@@ -4746,157 +4731,6 @@ export interface DashboardtypesJSONPatchOperationDTO {
value?: unknown;
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: DashboardtypesDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardForUserV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export interface DashboardtypesListedDashboardV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4913,17 +4747,6 @@ export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface TagtypesPostableTagDTO {
/**
* @type string
*/
key: string;
/**
* @type string
*/
value: string;
}
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -9826,40 +9649,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
@@ -9868,9 +9657,6 @@ export type CreateDashboardV2201 = {
status: string;
};
export type DeleteDashboardV2PathParameters = {
id: string;
};
export type GetDashboardV2PathParameters = {
id: string;
};
@@ -10703,46 +10489,6 @@ export type GetMyUser200 = {
status: string;
};
export type ListDashboardsForUserV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsForUserV2200 = {
data: DashboardtypesListableDashboardForUserV2DTO;
/**
* @type string
*/
status: string;
};
export type UnpinDashboardV2PathParameters = {
id: string;
};
export type PinDashboardV2PathParameters = {
id: string;
};
export type GetHosts200 = {
data: ZeustypesGettableHostDTO;
/**

View File

@@ -40,31 +40,13 @@ type SpeechRecognitionConstructor = new () => ISpeechRecognition;
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
// Some hardened/enterprise browsers install a getter
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
// due to your security policy") instead of leaving the property undefined.
// Because this resolves at module-evaluation time, an uncaught throw here aborts
// the entire bundle and the app renders a blank page. Read defensively so a
// throwing getter degrades to "unsupported" rather than crashing the app.
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
if (typeof window === 'undefined') {
return null;
}
try {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).SpeechRecognition ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).webkitSpeechRecognition ??
null
);
} catch {
return null;
}
}
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
resolveSpeechRecognitionAPI();
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).SpeechRecognition ??
(window as any).webkitSpeechRecognition ??
null)
: null;
export type SpeechRecognitionError =
| 'not-supported'

View File

@@ -1,7 +1,8 @@
.billingContainer {
margin-bottom: var(--spacing-20);
padding-top: 36px;
width: 90%;
margin: 0 auto var(--spacing-20);
margin: 0 auto;
.pageHeader {
margin-bottom: var(--spacing-8);

View File

@@ -1,6 +1,6 @@
.license-key-callout {
margin: var(--spacing-4) var(--spacing-6);
width: auto !important;
width: auto;
.license-key-callout__description {
display: flex;

View File

@@ -142,15 +142,6 @@
}
}
.reset-password-back-action {
margin-top: var(--spacing-12);
width: 100%;
button {
width: 100%;
}
}
@media (max-width: 768px) {
width: 100%;
padding: 0 16px;

View File

@@ -1,10 +1,7 @@
import { ArrowLeft, CircleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { CircleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import APIError from 'types/api/error';
import './ResetPassword.styles.scss';
@@ -62,16 +59,6 @@ function TokenError({ error }: TokenErrorProps): JSX.Element {
</Typography.Text>
</div>
{error && <AuthError error={error} />}
<div className="reset-password-back-action">
<Button
variant="solid"
data-testid="back-to-login"
prefix={<ArrowLeft size={12} />}
onClick={(): void => history.push(ROUTES.LOGIN)}
>
Back to login
</Button>
</div>
</div>
</AuthPageContainer>
);

View File

@@ -119,10 +119,6 @@
border-radius: 0px 4px 4px 0px;
background: var(--l3-background);
&.version-container-standalone {
border-radius: 4px;
}
}
.version {
@@ -1135,9 +1131,17 @@
.settings-dropdown,
.help-support-dropdown {
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
.ant-dropdown-menu-item {
min-height: 32px;
.ant-dropdown-menu-title-content {
color: var(--l1-foreground) !important;
}
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
}
}

View File

@@ -1010,7 +1010,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
<img src={signozBrandLogoUrl} alt="SigNoz" />
</div>
{(licenseTag || currentVersion) && (
{licenseTag && (
<div
className={cx(
'brand-title-section',
@@ -1021,7 +1021,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
'version-update-notification',
)}
>
{licenseTag && <span className="license-type"> {licenseTag} </span>}
<span className="license-type"> {licenseTag} </span>
{currentVersion && (
<Tooltip
@@ -1043,12 +1043,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
)
}
>
<div
className={cx(
'version-container',
!licenseTag && 'version-container-standalone',
)}
>
<div className="version-container">
<span
className={cx('version', changelog && 'version-clickable')}
onClick={onClickVersionHandler}

View File

@@ -1,53 +0,0 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const [onModal, Content] = Modal.useModal();
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
dashboardId,
{ confirm: onModal.confirm },
);
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
useEffect(() => {
document.title = dashboardTitle || document.title;
}, [dashboardTitle]);
const errorMessage = isError
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return (
<>
{Content}
<DashboardContainer />
</>
);
}
export default DashboardPage;

View File

@@ -1,15 +1,53 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
import DashboardPage from './DashboardPage';
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardPageEntry(): JSX.Element {
const isDashboardV2 = useIsDashboardV2();
const [onModal, Content] = Modal.useModal();
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
dashboardId,
{ confirm: onModal.confirm },
);
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
useEffect(() => {
document.title = dashboardTitle || document.title;
}, [dashboardTitle]);
const errorMessage = isError
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return (
<>
{Content}
<DashboardContainer />
</>
);
}
export default DashboardPageEntry;
export default DashboardPage;

View File

@@ -4,7 +4,7 @@ import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
@@ -16,7 +16,7 @@ import type { GridItem } from './utils';
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesPatchOpDTO;
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const PANEL_REF_PREFIX = '#/spec/panels/';

View File

@@ -1,15 +1,3 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './DashboardsListPage';
// Serves the V2 dashboards list when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 list. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardsListPageEntry(): JSX.Element {
const isDashboardV2 = useIsDashboardV2();
return isDashboardV2 ? <DashboardsListPageV2 /> : <DashboardsListPage />;
}
export default DashboardsListPageEntry;
export default DashboardsListPage;

View File

@@ -8,10 +8,6 @@ import {
createDashboardV2,
useListDashboardsV2,
} from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -28,6 +24,8 @@ import {
useSearch,
useSortColumn,
useSortOrder,
type SortColumn,
type SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import type { DashboardListItem } from '../../utils';
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
@@ -133,10 +131,6 @@ function DashboardsList(): JSX.Element {
tags: null,
spec: {
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
layouts: [],
panels: {},
variables: [],
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
},
});
safeNavigate(
@@ -156,7 +150,7 @@ function DashboardsList(): JSX.Element {
}, []);
const onSortChange = useCallback(
(column: DashboardtypesListSortDTO): void => {
(column: SortColumn): void => {
void setSortColumn(column);
void setPage(1);
},
@@ -164,7 +158,7 @@ function DashboardsList(): JSX.Element {
);
const onOrderChange = useCallback(
(order: DashboardtypesListOrderDTO): void => {
(order: SortOrder): void => {
void setSortOrder(order);
void setPage(1);
},

View File

@@ -7,18 +7,18 @@ import {
HdmiPort,
} from '@signozhq/icons';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
SortColumn,
SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import styles from './ListHeader.module.scss';
interface Props {
sortColumn: DashboardtypesListSortDTO;
onSortChange: (column: DashboardtypesListSortDTO) => void;
sortOrder: DashboardtypesListOrderDTO;
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
sortColumn: SortColumn;
onSortChange: (column: SortColumn) => void;
sortOrder: SortOrder;
onOrderChange: (order: SortOrder) => void;
onConfigureMetadata: () => void;
}
@@ -44,57 +44,49 @@ function ListHeader({
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
onClick={(): void => onSortChange('name')}
data-testid="sort-by-name"
>
Name
{sortColumn === DashboardtypesListSortDTO.name && <Check size={14} />}
{sortColumn === 'name' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void =>
onSortChange(DashboardtypesListSortDTO.created_at)
}
onClick={(): void => onSortChange('created_at')}
data-testid="sort-by-last-created"
>
Last created
{sortColumn === DashboardtypesListSortDTO.created_at && (
<Check size={14} />
)}
{sortColumn === 'created_at' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void =>
onSortChange(DashboardtypesListSortDTO.updated_at)
}
onClick={(): void => onSortChange('updated_at')}
data-testid="sort-by-last-updated"
>
Last updated
{sortColumn === DashboardtypesListSortDTO.updated_at && (
<Check size={14} />
)}
{sortColumn === 'updated_at' && <Check size={14} />}
</Button>
<div className={styles.sortDivider} />
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
onClick={(): void => onOrderChange('asc')}
data-testid="sort-order-asc"
>
Ascending
{sortOrder === DashboardtypesListOrderDTO.asc && <Check size={14} />}
{sortOrder === 'asc' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
onClick={(): void => onOrderChange('desc')}
data-testid="sort-order-desc"
>
Descending
{sortOrder === DashboardtypesListOrderDTO.desc && <Check size={14} />}
{sortOrder === 'desc' && <Check size={14} />}
</Button>
</div>
}

View File

@@ -1,5 +1,5 @@
/* Shared building blocks for the dashboards-list view states. */
/* Composed via CSS-modules `composes:` from each state's own SCSS. */
// Shared building blocks for the dashboards-list view states.
// Composed via CSS-modules `composes:` from each state's own SCSS.
.cardWrapper {
display: flex;

View File

@@ -1,7 +1,3 @@
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
parseAsInteger,
parseAsString,
@@ -11,31 +7,26 @@ import {
type UseQueryStateReturn,
} from 'nuqs';
export const SORT_COLUMNS = Object.values(DashboardtypesListSortDTO);
export const SORT_ORDERS = Object.values(DashboardtypesListOrderDTO);
export const SORT_COLUMNS = ['updated_at', 'created_at', 'name'] as const;
export type SortColumn = (typeof SORT_COLUMNS)[number];
export const SORT_ORDERS = ['asc', 'desc'] as const;
export type SortOrder = (typeof SORT_ORDERS)[number];
const opts: Options = { history: 'push' };
export const useSortColumn = (): UseQueryStateReturn<
DashboardtypesListSortDTO,
DashboardtypesListSortDTO
> =>
export const useSortColumn = (): UseQueryStateReturn<SortColumn, SortColumn> =>
useQueryState(
'sort',
parseAsStringLiteral(SORT_COLUMNS)
.withDefault(DashboardtypesListSortDTO.updated_at)
.withDefault('updated_at')
.withOptions(opts),
);
export const useSortOrder = (): UseQueryStateReturn<
DashboardtypesListOrderDTO,
DashboardtypesListOrderDTO
> =>
export const useSortOrder = (): UseQueryStateReturn<SortOrder, SortOrder> =>
useQueryState(
'order',
parseAsStringLiteral(SORT_ORDERS)
.withDefault(DashboardtypesListOrderDTO.desc)
.withOptions(opts),
parseAsStringLiteral(SORT_ORDERS).withDefault('desc').withOptions(opts),
);
export const usePage = (): UseQueryStateReturn<number, number> =>

View File

@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,

View File

@@ -2,7 +2,7 @@ import { Logout } from 'api/utils';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { createErrorResponse, rest, server } from 'mocks-server/server';
import { render, screen, waitFor, fireEvent } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import ResetPassword from '../index';
@@ -103,7 +103,6 @@ describe('ResetPassword Page', () => {
expect(
screen.getByText(/reset password token does not exist/i),
).toBeInTheDocument();
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
});
it('shows "token is expired" when token is expired (401) without redirecting to login', async () => {
@@ -138,32 +137,6 @@ describe('ResetPassword Page', () => {
// 401 from this endpoint must NOT trigger logout/redirect
expect(mockHistoryPush).not.toHaveBeenCalledWith(ROUTES.LOGIN);
expect(Logout).not.toHaveBeenCalled();
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
});
it('navigates to login when "Back to login" is clicked on error screen', async () => {
server.use(
rest.post(
VERIFY_TOKEN_ENDPOINT,
createErrorResponse(
404,
'reset_password_token_not_found',
'reset password token does not exist',
),
),
);
window.history.pushState({}, '', '/password-reset?token=invalid-token');
render(<ResetPassword />, undefined, {
initialRoute: '/password-reset?token=invalid-token',
});
await waitFor(() => {
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('back-to-login'));
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
it('redirects to login when no token is in the URL', async () => {

View File

@@ -1,12 +1,8 @@
.settings-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
max-height: 100vh;
overflow: hidden;
.settings-page-header {
flex-shrink: 0;
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
backdrop-filter: blur(20px);
@@ -28,14 +24,13 @@
}
.settings-page-content-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
align-items: flex-start;
.settings-page-sidenav {
width: 240px;
height: calc(100vh - 48px);
border-right: 1px solid var(--l1-border);
background: var(--l1-background);
padding-top: var(--padding-1);
@@ -79,6 +74,7 @@
.settings-page-content {
flex: 1;
height: calc(100vh - 48px);
background: var(--l1-background);
padding: 10px 8px;
overflow-y: auto;

View File

@@ -48,7 +48,9 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts"
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
],
"include": [
"./src",

2
go.mod
View File

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

View File

@@ -14,42 +14,6 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
ID: "ListDashboardsV2",
Tags: []string{"dashboard"},
Summary: "List dashboards (v2)",
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListForUserV2), handler.OpenAPIDef{
ID: "ListDashboardsForUserV2",
Tags: []string{"dashboard"},
Summary: "List dashboards for the current user (v2)",
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardForUserV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
@@ -125,23 +89,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
ID: "DeleteDashboardV2",
Tags: []string{"dashboard"},
Summary: "Delete dashboard (v2)",
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
@@ -176,42 +123,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
// ViewAccess: pinning only mutates the calling user's pin list, not the
// dashboard itself — anyone who can view a dashboard can bookmark it.
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
ID: "PinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Pin a dashboard for the current user (v2)",
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
ID: "UnpinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unpin a dashboard for the current user (v2)",
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -451,6 +451,23 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v5/query_range/dry_run", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
ID: "QueryRangeDryRunV5",
Tags: []string{"querier"},
Summary: "Query range dry run",
Description: "Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default returns a lightweight per-query verdict (valid/error/warnings) plus a top-level score (0-100 granule-skip selectivity; higher is better). Pass ?verbose=true to also include the rendered underlying ClickHouse statement(s) for each query (each carrying its own granuleSkipScore). Pass ?explain=plan|estimate to attach the corresponding ClickHouse EXPLAIN output to each statement (implies verbose). Pass ?score=false to skip the score for the cheapest validation-only preview. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.",
Request: new(qbtypes.QueryRangeRequest),
RequestQuery: new(qbtypes.QueryRangePreviewParams),
RequestContentType: "application/json",
Response: new(qbtypes.QueryRangePreviewResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/substitute_vars", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.ReplaceVariables), handler.OpenAPIDef{
ID: "ReplaceVariables",
Tags: []string{"querier"},

View File

@@ -50,8 +50,8 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
// Health endpoints don't act on resources.
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
return nil
}

View File

@@ -7,197 +7,166 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addRoleRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.GetObjects, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Patch, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.PatchObjects, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func roleCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleInstanceSelectorCallback(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -1,10 +1,13 @@
package signozapiserver
import (
"context"
"bytes"
"encoding/json"
"io"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -14,56 +17,41 @@ import (
)
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -84,117 +72,89 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetRoles, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.GetRoles, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.SetRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleAttachSelectorFromBody, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.PathParam("rid")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.DeleteRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleDetachSelectorFromPath, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -215,209 +175,208 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Update, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Update, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceServiceAccount,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.CreateFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbCreate}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyCollectionSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("fid"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("fid"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceServiceAccount,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
ChildIDs: coretypes.OneID(coretypes.PathParam("fid")),
},
),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.RevokeFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDelete}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
// already extracted by the ResourceDef (path or body); this only does the
// UUID -> name lookup the FGA object string requires. Shared by service account
// and role routes.
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
func (provider *provider) roleDetachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(ctx, orgID, roleID)
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
resource.Type().MustSelector(role.Name),
resource.Type().MustSelector(coretypes.WildCardSelectorString),
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleAttachSelectorFromBody(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body = io.NopCloser(bytes.NewReader(body))
postableRole := new(serviceaccounttypes.PostableServiceAccountRole)
if err := json.Unmarshal(body, postableRole); err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), postableRole.ID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
fid := mux.Vars(req)["fid"]
fidSelector, err := coretypes.TypeMetaResource.Selector(fid)
if err != nil {
return nil, err
}
return []coretypes.Selector{
fidSelector,
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
id := mux.Vars(req)["id"]
idSelector, err := coretypes.TypeServiceAccount.Selector(id)
if err != nil {
return nil, err
}
return []coretypes.Selector{
idSelector,
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -20,16 +20,16 @@ func newTestSettings() factory.ScopedProviderSettings {
return factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "auditorserver_test")
}
func newTestEvent(resource coretypes.Resource, action coretypes.Verb) audittypes.AuditEvent {
func newTestEvent(resource string, action coretypes.Verb) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource.Kind(), action),
EventName: audittypes.NewEventName(coretypes.MustNewKind(resource), action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
Resource: resource,
ResourceKind: coretypes.MustNewKind(resource),
},
}
}
@@ -84,7 +84,7 @@ func TestAdd_FlushesOnBatchSize(t *testing.T) {
go func() { _ = server.Start(ctx) }()
for i := 0; i < 3; i++ {
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
}
assert.Eventually(t, func() bool {
@@ -113,7 +113,7 @@ func TestAdd_FlushesOnInterval(t *testing.T) {
go func() { _ = server.Start(ctx) }()
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate))
server.Add(ctx, newTestEvent("user", coretypes.VerbUpdate))
assert.Eventually(t, func() bool {
return exported.Load() == 1
@@ -131,9 +131,9 @@ func TestAdd_DropsWhenBufferFull(t *testing.T) {
ctx := context.Background()
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbUpdate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbUpdate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbDelete))
assert.Equal(t, 2, server.queueLen())
}
@@ -156,7 +156,7 @@ func TestStop_DrainsRemainingEvents(t *testing.T) {
go func() { _ = server.Start(ctx) }()
for i := 0; i < 5; i++ {
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceRule, coretypes.VerbCreate))
server.Add(ctx, newTestEvent("alert-rule", coretypes.VerbCreate))
}
require.NoError(t, server.Stop(ctx))
@@ -181,8 +181,8 @@ func TestAdd_ContinuesAfterExportFailure(t *testing.T) {
go func() { _ = server.Start(ctx) }()
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
assert.Eventually(t, func() bool {
return calls.Load() >= 1
@@ -213,7 +213,7 @@ func TestAdd_ConcurrentSafety(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
}()
}
wg.Wait()

View File

@@ -15,13 +15,13 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
ResourceDefs() []ResourceDef
AuditDef() *AuditDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
resourceDefs []ResourceDef
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
@@ -130,6 +130,6 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
}
}
func (handler *handler) ResourceDefs() []ResourceDef {
return handler.resourceDefs
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

@@ -1,9 +1,25 @@
package handler
import (
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
// Option configures optional behaviour on a handler created by New.
type Option func(*handler)
func WithResourceDefs(defs ...ResourceDef) Option {
type AuditDef struct {
ResourceKind coretypes.Kind // Typeable.Kind() value, e.g. "dashboard", "user".
Action coretypes.Verb // create, update, delete, etc.
Category audittypes.ActionCategory // access_control, configuration_change, etc.
ResourceIDParam string // Gorilla mux path param name for the resource ID.
}
// WithAudit attaches an AuditDef to the handler. The actual audit event
// emission is handled by the middleware layer, which reads the AuditDef
// from the matched route's handler.
func WithAuditDef(def AuditDef) Option {
return func(h *handler) {
h.resourceDefs = append(h.resourceDefs, defs...)
h.auditDef = &def
}
}

View File

@@ -1,99 +0,0 @@
package handler
import "github.com/SigNoz/signoz/pkg/types/coretypes"
type ResourceDef interface {
// resolveRequest is unexported to seal the interface. It returns a slice so a
// single def can fan out (e.g. a telemetry query touching multiple signals).
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
}
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
resolved := make([]coretypes.ResolvedResource, 0, len(defs))
for _, def := range defs {
resolved = append(resolved, def.resolveRequest(ec)...)
}
return resolved
}
// BasicResourceDef checks a single resource for one verb.
type BasicResourceDef struct {
Resource coretypes.Resource
Verb coretypes.Verb
Category coretypes.ActionCategory
ID coretypes.ResourceIDExtractor
Selector coretypes.SelectorFunc
}
func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResource(
def.Verb,
def.Category,
def.Resource,
def.ID,
def.Selector,
ec,
),
}
}
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
// both source and target are authz-checked.
type AttachDetachSiblingResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
SourceResource coretypes.Resource
SourceIDs coretypes.ResourceIDsExtractor
SourceSelector coretypes.SelectorFunc
TargetResource coretypes.Resource
TargetIDs coretypes.ResourceIDsExtractor
TargetSelector coretypes.SelectorFunc
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
),
}
}
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
// rides along for audit context.
type AttachDetachParentChildResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
ParentResource coretypes.Resource
ParentID coretypes.ResourceIDExtractor
ParentSelector coretypes.SelectorFunc
ChildResource coretypes.Resource
ChildIDs coretypes.ResourceIDsExtractor
}
func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.ParentResource,
coretypes.OneID(def.ParentID),
def.ParentSelector,
def.ChildResource,
def.ChildIDs,
nil,
true,
ec,
),
}
}

View File

@@ -12,10 +12,10 @@ import (
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
const (
@@ -61,12 +61,6 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
// Capture the body only when a resolved resource derives an id from it (e.g. a create).
if coretypes.ShouldCaptureResponseBody(req.Context()) {
writer.EnableBodyCapture()
}
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
@@ -86,7 +80,7 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if statusCode >= 400 && responseBuffer.Len() != 0 {
if responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
@@ -100,85 +94,76 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
return
}
resolved, err := coretypes.ResolvedResourcesFromContext(req.Context())
if err != nil || len(resolved) == 0 {
def := auditDefFromRequest(req)
if def == nil {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceKind,
errorType,
errorCode,
)
for _, resource := range resolved {
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()
switch typed := resource.(type) {
case coretypes.ResolvedResourceWithTargetResource:
for _, sourceID := range typed.SourceIDs() {
for _, targetID := range typed.TargetIDs() {
attributesList := []audittypes.ResourceAttributes{
audittypes.NewRelatedResourceAttributes(
typed.SourceResource(),
sourceID,
typed.TargetResource(),
targetID,
),
}
// Sibling peers are symmetric, so mirror the event from the target's side too.
if !typed.IsParentChild() {
attributesList = append(attributesList, audittypes.NewRelatedResourceAttributes(
typed.TargetResource(),
targetID,
typed.SourceResource(),
sourceID,
))
}
for _, attributes := range attributesList {
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
verb,
category,
claims,
attributes,
errorType,
errorCode,
))
}
}
}
default:
for _, id := range resource.SourceIDs() {
attributes := audittypes.NewResourceAttributes(resource.SourceResource(), id)
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
verb,
category,
claims,
attributes,
errorType,
errorCode,
))
}
}
}
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
}

View File

@@ -1,8 +1,6 @@
package middleware
import (
"context"
"fmt"
"log/slog"
"net/http"
@@ -21,6 +19,18 @@ const (
authzDeniedMessage string = "::AUTHZ-DENIED::"
)
type AuthZCheckDef struct {
Relation authtypes.Relation
Resource coretypes.Resource
SelectorCallback selectorCallbackWithClaimsFn
Roles []string
}
// AuthZCheckGroup is a set of checks OR'd together.
// At least one check in the group must pass for the group to pass.
type AuthZCheckGroup []AuthZCheckDef
type selectorCallbackWithClaimsFn func(*http.Request, authtypes.Claims) ([]coretypes.Selector, error)
type selectorCallbackWithoutClaimsFn func(*http.Request, []*types.Organization) ([]coretypes.Selector, valuer.UUID, error)
type AuthZ struct {
@@ -191,9 +201,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
})
}
// CheckResources authorizes every resolved resource for the route. roles are the
// allowed role names (the OSS role-gate); the resource selectors drive the EE check.
func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string) http.HandlerFunc {
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithClaimsFn, roles []string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
@@ -202,7 +210,40 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
return
}
resolved, err := coretypes.ResolvedResourcesFromContext(ctx)
selectors, err := cb(req, claims)
if err != nil {
render.Error(rw, err)
return
}
roleSelectors := []coretypes.Selector{}
for _, role := range roles {
roleSelectors = append(roleSelectors, coretypes.TypeRole.MustSelector(role))
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, valuer.MustNewUUID(claims.OrgID), relation, typeable, selectors, roleSelectors)
if err != nil {
render.Error(rw, err)
return
}
next(rw, req)
})
}
// CheckAll verifies groups of permission checks.
// Within each group, checks are OR'd (any check passing = group passes).
// Across groups, results are AND'd (all groups must pass).
//
// This model expresses any combination:
// - Single check: []AuthZCheckGroup{{checkA}}
// - Pure AND: []AuthZCheckGroup{{checkA}, {checkB}}
// - Cross-resource OR: []AuthZCheckGroup{{checkA, checkB}}
// - Mixed (A OR B) AND C: []AuthZCheckGroup{{checkA, checkB}, {checkC}}
func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGroup) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
@@ -210,23 +251,33 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
orgID := valuer.MustNewUUID(claims.OrgID)
roleSelectors := make([]coretypes.Selector, len(roles))
for idx, role := range roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
for _, group := range groups {
groupPassed := false
var lastErr error
for _, resource := range resolved {
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return
}
target, ok := resource.(coretypes.ResolvedResourceWithTargetResource)
if ok && !target.IsParentChild() {
if err := middleware.checkResource(ctx, claims, orgID, target.Verb(), target.TargetResource(), target.TargetIDs(), target.TargetSelector(), roleSelectors); err != nil {
for _, check := range group {
selectors, err := check.SelectorCallback(req, claims)
if err != nil {
render.Error(rw, err)
return
}
roleSelectors := make([]coretypes.Selector, len(check.Roles))
for idx, role := range check.Roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgID, check.Relation, check.Resource, selectors, roleSelectors)
if err == nil {
groupPassed = true
break
}
lastErr = err
}
if !groupPassed {
render.Error(rw, lastErr)
return
}
}
@@ -234,68 +285,6 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
})
}
func (middleware *AuthZ) checkResource(
ctx context.Context,
claims authtypes.Claims,
orgID valuer.UUID,
verb coretypes.Verb,
resource coretypes.Resource,
ids []string,
selector coretypes.SelectorFunc,
roleSelectors []coretypes.Selector,
) error {
if selector == nil {
return errors.New(errors.TypeInternal, errors.CodeInternal, "resolved resource is missing a selector")
}
for _, id := range ids {
selectors, err := selector(ctx, resource, id, orgID)
if err != nil {
return err
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
orgID,
authtypes.Relation{Verb: verb},
resource,
selectors,
roleSelectors,
)
if err == nil {
continue
}
if !errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
return err
}
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
principal := fmt.Sprintf("%s/%s", claims.Principal.StringValue(), claims.IdentityID())
if id != "" {
return errors.Newf(
errors.TypeForbidden,
authtypes.ErrCodeAuthZForbidden,
"%s is not authorized to perform %s on resource %q",
principal,
resource.Scope(verb),
id,
)
}
return errors.Newf(
errors.TypeForbidden,
authtypes.ErrCodeAuthZForbidden,
"%s is not authorized to perform %s",
principal,
resource.Scope(verb),
)
}
return nil
}
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithoutClaimsFn, roles []string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -1,67 +0,0 @@
package middleware
import (
"bytes"
"io"
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
)
// Resource resolves a route's declared ResourceDefs and stashes the result in
// the request context for authz and audit to read.
type Resource struct {
logger *slog.Logger
}
func NewResource(logger *slog.Logger) *Resource {
return &Resource{logger: logger.With(slog.String("pkg", pkgname))}
}
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
defs := resourceDefsFromRequest(req)
if len(defs) == 0 {
next.ServeHTTP(rw, req)
return
}
// Buffer the body once so extractors can read it and the handler still sees a fresh reader.
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(body))
}
extractorCtx := coretypes.ExtractorContext{
Request: req,
RequestBody: body,
}
resolved := handler.ResolveRequest(defs, extractorCtx)
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
next.ServeHTTP(rw, req.WithContext(ctx))
})
}
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.ResourceDefs()
}

View File

@@ -23,14 +23,9 @@ type responseCapture interface {
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Populated for error
// responses (status >= 400), or for any response once EnableBodyCapture is called.
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
BodyBytes() []byte
// EnableBodyCapture forces capture of the response body regardless of status
// code (still bounded by maxResponseBodyCapture). Must be called before the
// handler writes the response.
EnableBodyCapture()
}
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
@@ -77,13 +72,12 @@ func (b *byteBuffer) String() string {
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
forceCaptureBody bool
bodyBytesLeft int
statusCode int
writeError error
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
@@ -104,17 +98,13 @@ func (writer *nonFlushingResponseCapture) Header() http.Header {
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 400 || writer.forceCaptureBody {
if statusCode >= 400 {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
func (writer *nonFlushingResponseCapture) EnableBodyCapture() {
writer.forceCaptureBody = true
}
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
if writer.statusCode == 0 {

View File

@@ -61,23 +61,11 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}
type Handler interface {
@@ -108,10 +96,6 @@ type Handler interface {
GetV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
ListForUserV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
@@ -119,10 +103,4 @@ type Handler interface {
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
PinV2(http.ResponseWriter, *http.Request)
UnpinV2(http.ResponseWriter, *http.Request)
DeleteV2(http.ResponseWriter, *http.Request)
}

View File

@@ -1,44 +0,0 @@
package impldashboard
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
type Compiled struct {
SQL string
Args []any
}
func (c Compiled) IsEmpty() bool {
return c.SQL == ""
}
// Compile always returns a non-nil *Compiled. An empty query (or one that
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
// SQL != "" rather than a nil check.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
return &Compiled{}, nil
}
queryVisitor := newVisitor(formatter)
sql, args, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
return &Compiled{
SQL: sql,
Args: args,
}, nil
}

View File

@@ -1,526 +0,0 @@
package impldashboard
import (
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
type compileCase struct {
subtestName string
dslQueryToCompile string
emptyQueryExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
}
// kindArg is the tag_relation.kind value bound into every tag EXISTS subquery
// (stored double-encoded, hence the embedded quotes). It leads each tag
// predicate's args, ahead of the tag key.
const kindArg = `"dashboard"`
func runCompileCases(t *testing.T, cases []compileCase) {
t.Helper()
for _, c := range cases {
t.Run(c.subtestName, func(t *testing.T) {
out, err := Compile(c.dslQueryToCompile, formatter(t))
if c.expectedErrShouldContain != "" {
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
return
}
require.NoError(t, err)
if c.emptyQueryExpected {
assert.True(t, out.IsEmpty())
return
}
require.NotNil(t, out)
if c.expectedSQL != "" {
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
}
if c.expectedArgs != nil {
require.Len(t, out.Args, len(c.expectedArgs))
for i, want := range c.expectedArgs {
// time.Time values can carry semantically-equal instants
// in different *Location representations (UTC vs Local vs
// FixedZone). Compare via .Equal() instead of DeepEqual.
if wantT, ok := want.(time.Time); ok {
gotT, ok := out.Args[i].(time.Time)
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
continue
}
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
}
}
})
}
}
func TestCompile_Empty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
})
}
func TestCompile_Name(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name =",
dslQueryToCompile: `name = 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
expectedArgs: []any{"overview"},
},
{
// QUOTED_TEXT in the grammar covers both '…' and "…" — visitor
// strips whichever quote pair surrounds the value.
subtestName: "name = with double-quoted value",
dslQueryToCompile: `name = "something"`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
expectedArgs: []any{"something"},
},
{
subtestName: "name CONTAINS",
dslQueryToCompile: `name CONTAINS 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%overview%"},
},
{
subtestName: "name ILIKE — emitted as LOWER(col) LIKE LOWER(?) for dialect parity",
dslQueryToCompile: `name ILIKE 'Prod%'`,
expectedSQL: `lower(json_extract("dashboard"."data", '$.spec.display.name')) LIKE LOWER(?) ESCAPE '\'`,
expectedArgs: []any{"Prod%"},
},
{
subtestName: "CONTAINS escapes % in user input",
dslQueryToCompile: `name CONTAINS '50%'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'`,
expectedArgs: []any{`%50\%%`},
},
})
}
func TestCompile_CreatedByLocked(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "created_by LIKE",
dslQueryToCompile: `created_by LIKE '%@signoz.io'`,
expectedSQL: `dashboard.created_by LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%@signoz.io"},
},
{
subtestName: "locked = true",
dslQueryToCompile: `locked = true`,
expectedSQL: `dashboard.locked = ?`,
expectedArgs: []any{true},
},
})
}
func TestCompile_Timestamps(t *testing.T) {
ist := time.FixedZone("+05:30", 5*60*60+30*60)
runCompileCases(t, []compileCase{
{
subtestName: "created_at >= RFC3339",
dslQueryToCompile: `created_at >= '2026-03-10T00:00:00Z'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)},
},
{
subtestName: "updated_at BETWEEN",
dslQueryToCompile: `updated_at BETWEEN '2026-03-10T00:00:00Z' AND '2026-03-20T00:00:00Z'`,
expectedSQL: `dashboard.updated_at BETWEEN ? AND ?`,
expectedArgs: []any{
time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC),
time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
},
},
{
subtestName: "created_at >= IST timestamp",
dslQueryToCompile: `created_at >= '2026-03-10T05:30:00+05:30'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 5, 30, 0, 0, ist)},
},
})
}
// Tag operators wrap each predicate in EXISTS / NOT EXISTS. Any non-reserved
// key is a tag key — `team = 'pulse'` matches a tag with key=team value=pulse,
// `tag = 'prod'` matches a tag with key=tag value=prod, and so on.
func TestCompile_Tag(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "team = wraps in EXISTS",
dslQueryToCompile: `team = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
{
subtestName: "tag = is just a regular tag-key filter",
dslQueryToCompile: `tag = 'database'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "tag", "database"},
},
{
subtestName: "team != wraps in NOT EXISTS with positive inner",
dslQueryToCompile: `team != 'pulse'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
{
subtestName: "team IN — inner is single placeholder list on t.value",
dslQueryToCompile: `team IN ['pulse', 'events']`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{kindArg, "team", "pulse", "events"},
},
{
subtestName: "team NOT IN",
dslQueryToCompile: `team NOT IN ['pulse', 'events']`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{kindArg, "team", "pulse", "events"},
},
{
subtestName: "team LIKE — wildcard on value",
dslQueryToCompile: `team LIKE 'pulse%'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ? ESCAPE '\'
)`,
expectedArgs: []any{kindArg, "team", "pulse%"},
},
{
subtestName: "team NOT LIKE",
dslQueryToCompile: `team NOT LIKE 'staging%'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ? ESCAPE '\'
)`,
expectedArgs: []any{kindArg, "team", "staging%"},
},
{
subtestName: "database EXISTS — asserts a tag with key=database is present",
dslQueryToCompile: `database EXISTS`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{kindArg, "database"},
},
{
subtestName: "database NOT EXISTS",
dslQueryToCompile: `database NOT EXISTS`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{kindArg, "database"},
},
{
subtestName: "tag-key matching is case-insensitive — TEAM lowercased",
dslQueryToCompile: `TEAM = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
})
}
func TestCompile_BooleanComposition(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "AND chain — flat arg list",
dslQueryToCompile: `locked = true AND created_by = 'a@b.com'`,
expectedSQL: `(dashboard.locked = ? AND dashboard.created_by = ?)`,
expectedArgs: []any{true, "a@b.com"},
},
{
subtestName: "OR chain",
dslQueryToCompile: `locked = true OR created_by = 'a@b.com'`,
expectedSQL: `(dashboard.locked = ? OR dashboard.created_by = ?)`,
expectedArgs: []any{true, "a@b.com"},
},
{
subtestName: "parens preserve precedence",
dslQueryToCompile: `(locked = true OR locked = false) AND created_by = 'a@b.com'`,
expectedSQL: `((dashboard.locked = ? OR dashboard.locked = ?) AND dashboard.created_by = ?)`,
expectedArgs: []any{true, false, "a@b.com"},
},
})
}
// Distinct from operator-suffix negation (NOT IN / NOT LIKE / NOT EXISTS).
// Driven by the unaryExpression rule (`NOT? primary`), so NOT binds to
// exactly one primary and only widens via parens.
func TestCompile_NOT(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "NOT on a single comparison",
dslQueryToCompile: `NOT name = 'foo'`,
expectedSQL: `NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT binds tightly to its primary in an AND chain",
dslQueryToCompile: `NOT name = 'foo' AND created_by = 'alice'`,
expectedSQL: `(NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?) AND dashboard.created_by = ?)`,
expectedArgs: []any{"foo", "alice"},
},
{
subtestName: "NOT applied to the second term in an AND chain",
dslQueryToCompile: `locked = true AND NOT name = 'foo'`,
expectedSQL: `(dashboard.locked = ? AND NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?))`,
expectedArgs: []any{true, "foo"},
},
{
subtestName: "NOT around a parenthesized OR",
dslQueryToCompile: `NOT (locked = true OR created_by = 'a@b.com')`,
expectedSQL: `NOT ((dashboard.locked = ? OR dashboard.created_by = ?))`,
expectedArgs: []any{true, "a@b.com"},
},
{
subtestName: "double NOT via parens",
dslQueryToCompile: `NOT (NOT name = 'foo')`,
expectedSQL: `NOT (NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?))`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT on a tag equality",
dslQueryToCompile: `NOT team = 'pulse'`,
expectedSQL: `
NOT (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
{
subtestName: "NOT team = ... AND name = ...",
dslQueryToCompile: `NOT team = 'pulse' AND name = 'overview'`,
expectedSQL: `
(
NOT (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)
AND json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: []any{kindArg, "team", "pulse", "overview"},
},
})
}
func TestCompile_ComplexExamples(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name CONTAINS + tag LIKE + created_by + database =",
dslQueryToCompile: `name CONTAINS 'overview' AND tag LIKE 'prod%' AND created_by = 'naman.verma@signoz.io' AND database = 'mongo'`,
expectedSQL: `
(
json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ? ESCAPE '\'
)
AND dashboard.created_by = ?
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
))`,
expectedArgs: []any{"%overview%", kindArg, "tag", "prod%", "naman.verma@signoz.io", kindArg, "database", "mongo"},
},
{
subtestName: "team IN AND database EXISTS",
dslQueryToCompile: `team IN ['pulse', 'events'] AND database EXISTS`,
expectedSQL: `
(
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
))`,
expectedArgs: []any{kindArg, "team", "pulse", "events", kindArg, "database"},
},
{
subtestName: "nested OR / AND with parens",
dslQueryToCompile: `(database IN ['sql', 'redis', 'mongo'] OR name LIKE '%database%') AND (team = 'pulse' OR name LIKE '%pulse%')`,
expectedSQL: `
(
(
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?, ?)
)
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'
)
AND (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'
))`,
expectedArgs: []any{kindArg, "database", "sql", "redis", "mongo", "%database%", kindArg, "team", "pulse", "%pulse%"},
},
})
}
func TestCompile_Rejections(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "rejects op outside per-reserved-key allowlist",
dslQueryToCompile: `name BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects BETWEEN on a tag key",
dslQueryToCompile: `team BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects non-bool on locked",
dslQueryToCompile: `locked = 'yes'`,
expectedErrShouldContain: "boolean",
},
{
subtestName: "rejects non-RFC3339 timestamp",
dslQueryToCompile: `created_at >= 'not-a-date'`,
expectedErrShouldContain: "RFC3339",
},
{
subtestName: "rejects REGEXP — not yet supported",
dslQueryToCompile: `name REGEXP '.*'`,
expectedErrShouldContain: "REGEXP",
},
{
subtestName: "rejects syntax error from grammar",
dslQueryToCompile: `name = `,
expectedErrShouldContain: "syntax",
},
})
}
// Every key in dashboardtypes.ReservedOps must have a matching case in
// visitComparisonForReservedKeys; a key that's reserved but unhandled falls
// through to the "no handler for reserved key" error. Equal is accepted by all
// reserved keys, so `key = 'x'` always reaches the dispatch switch — a missing
// handler surfaces as that error regardless of whether the value type-checks.
func TestCompileReservedKeysAllHandled(t *testing.T) {
for key := range dashboardtypes.ReservedOps {
t.Run(string(key), func(t *testing.T) {
_, err := Compile(string(key)+` = 'x'`, formatter(t))
if err != nil {
assert.NotContains(t, err.Error(), "no handler for reserved key",
"reserved key %q has no handler in visitComparisonForReservedKeys", key)
}
})
}
}
func formatter(t *testing.T) sqlstore.SQLFormatter {
t.Helper()
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return p.Formatter()
}
func normalizeSQL(s string) string {
s = strings.Join(strings.Fields(s), " ")
s = strings.ReplaceAll(s, "( ", "(")
s = strings.ReplaceAll(s, " )", ")")
return s
}

View File

@@ -1,581 +0,0 @@
package impldashboard
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/parser/filterquery"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
sqlbuilder "github.com/huandu/go-sqlbuilder"
)
// bunPlaceholderFlavor is any flavor that renders `?` placeholders, which bun
// re-binds to the actual backend (e.g. `$1` for Postgres) at query time.
const bunPlaceholderFlavor = sqlbuilder.SQLite
type visitor struct {
grammar.BaseFilterQueryVisitor
selectBuilder *sqlbuilder.SelectBuilder
formatter sqlstore.SQLFormatter
errors []string
}
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
return &visitor{
selectBuilder: sqlbuilder.NewSelectBuilder(),
formatter: formatter,
}
}
// compile turns the parse tree into `?`-placeholder WHERE SQL + arguments for bun.
func (v *visitor) compile(query string) (string, []any, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return "", nil, collector.Errors
}
condition, _ := v.visit(tree).(string)
if condition == "" {
return "", nil, nil
}
sql, arguments := v.selectBuilder.Args.CompileWithFlavor(condition, bunPlaceholderFlavor)
return sql, arguments, nil
}
func (v *visitor) visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
return tree.Accept(v)
}
// ════════════════════════════════════════════════════════════════════════
// methods from grammar.BaseFilterQueryVisitor that are overridden
// ════════════════════════════════════════════════════════════════════════
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.visit(ctx.Expression())
}
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.visit(ctx.OrExpression())
}
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
parts := ctx.AllAndExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.selectBuilder.Or(conditions...)
}
}
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
parts := ctx.AllUnaryExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.selectBuilder.And(conditions...)
}
}
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
condition, _ := v.visit(ctx.Primary()).(string)
if condition == "" {
return ""
}
if ctx.NOT() != nil {
return fmt.Sprintf("NOT (%s)", condition)
}
return condition
}
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
return v.visit(ctx.OrExpression())
}
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// Bare keys, values, full text, and function calls are not part of the
// dashboard list DSL.
v.addError("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
return ""
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
// a reserved DSL key (name, description, etc.) becomes a column-level
// predicate; any other identifier is treated as a tag key — the operator
// applies to the tag's value, with a case-insensitive match on the tag's key.
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key := strings.ToLower(strings.TrimSpace(ctx.Key().GetText()))
operation, ok := v.extractOperation(ctx)
if !ok {
return ""
}
if allowedOperations, isReserved := dashboardtypes.ReservedOps[dashboardtypes.DSLKey(key)]; isReserved {
return v.visitComparisonForReservedKeys(ctx, operation, dashboardtypes.DSLKey(key), allowedOperations)
}
return v.visitComparisonForTags(ctx, operation, key)
}
func (v *visitor) visitComparisonForReservedKeys(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
if _, allowed := allowedOperations[operation]; !allowed {
v.addError("operator %s is not allowed for key %q", operationName(operation), key)
return ""
}
switch key {
case dashboardtypes.DSLKeyName:
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyName, "$.spec.display.name")
case dashboardtypes.DSLKeyDescription:
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyDescription, "$.spec.display.description")
case dashboardtypes.DSLKeyCreatedAt:
return v.buildTimestampComparison(ctx, operation, "dashboard.created_at")
case dashboardtypes.DSLKeyUpdatedAt:
return v.buildTimestampComparison(ctx, operation, "dashboard.updated_at")
case dashboardtypes.DSLKeyCreatedBy:
return v.buildStringComparison(ctx, operation, dashboardtypes.DSLKeyCreatedBy, "dashboard.created_by")
case dashboardtypes.DSLKeyLocked:
return v.buildBoolComparison(ctx, operation, "dashboard.locked")
}
// Unreachable for real input: every dashboardtypes.ReservedOps key has a case above, and
// TestCompileReservedKeysAllHandled guards that the two stay in sync.
v.addError("no handler for reserved key %q", key)
return ""
}
func (v *visitor) visitComparisonForTags(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
if _, allowed := dashboardtypes.TagKeyOps[operation]; !allowed {
v.addError("operator %s is not allowed on a tag-key filter", operationName(operation))
return ""
}
return v.buildTagComparison(ctx, operation, tagKey)
}
func (v *visitor) extractOperation(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
// For operators that take an optional leading NOT, Inverse() maps each to
// its Not<X> counterpart.
maybeNot := func(operation qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if ctx.NOT() != nil {
return operation.Inverse()
}
return operation
}
switch {
case ctx.EQUALS() != nil:
return qbtypesv5.FilterOperatorEqual, true
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
return qbtypesv5.FilterOperatorNotEqual, true
case ctx.LT() != nil:
return qbtypesv5.FilterOperatorLessThan, true
case ctx.LE() != nil:
return qbtypesv5.FilterOperatorLessThanOrEq, true
case ctx.GT() != nil:
return qbtypesv5.FilterOperatorGreaterThan, true
case ctx.GE() != nil:
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
case ctx.BETWEEN() != nil:
return maybeNot(qbtypesv5.FilterOperatorBetween), true
case ctx.LIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorLike), true
case ctx.ILIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorILike), true
case ctx.CONTAINS() != nil:
return maybeNot(qbtypesv5.FilterOperatorContains), true
case ctx.REGEXP() != nil:
return maybeNot(qbtypesv5.FilterOperatorRegexp), true
case ctx.InClause() != nil:
return qbtypesv5.FilterOperatorIn, true
case ctx.NotInClause() != nil:
return qbtypesv5.FilterOperatorNotIn, true
case ctx.EXISTS() != nil:
return maybeNot(qbtypesv5.FilterOperatorExists), true
}
v.addError("could not determine operator in expression %q", ctx.GetText())
return qbtypesv5.FilterOperatorUnknown, false
}
// ─── per-key emitters ────────────────────────────────────────────────────────
func (v *visitor) buildJSONStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, jsonPath string) string {
columnExpression := string(v.formatter.JSONExtractString("dashboard.data", jsonPath))
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
}
func (v *visitor) buildStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, columnExpression string) string {
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
}
// buildStringOperation covers all the operators the spec allows on text-shaped keys
// (name, description, created_by, and a tag's value). Placeholders are interned
// into builder — the outer builder for column predicates, the subquery builder for
// tag-value predicates — so nested EXISTS arguments thread correctly.
func (v *visitor) buildStringOperation(builder *sqlbuilder.SelectBuilder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression, keyForError string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return builder.Equal(columnExpression, val)
case qbtypesv5.FilterOperatorNotEqual:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return builder.NotEqual(columnExpression, val)
case qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotLike {
like = "NOT LIKE"
}
// The user's % and _ stay as wildcards; ESCAPE pins backslash as the escape
// char so a literal `\` in the pattern is read the same on both dialects —
// Postgres defaults to `\`, SQLite has no default escape.
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var(val))
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
// SQLite has no ILIKE keyword and Postgres LIKE is case-sensitive — emit
// LOWER(col) LIKE LOWER(?) so behavior is identical on both dialects. ESCAPE
// pins backslash as the escape char (Postgres default; SQLite has none).
lowerColumn := string(v.formatter.LowerExpression(columnExpression))
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotILike {
like = "NOT LIKE"
}
return fmt.Sprintf("%s %s LOWER(%s) ESCAPE '\\'", lowerColumn, like, builder.Var(val))
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotContains {
like = "NOT LIKE"
}
// Escape the user's % and _ so they match literally, then wrap in wildcards.
// ESCAPE declares the backslash we just injected as the escape char — needed
// on SQLite (no default) and a harmless restatement of the Postgres default.
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(val)
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var("%"+escaped+"%"))
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
v.addError("REGEXP filtering on %q is not yet supported", keyForError)
return ""
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
values, ok := v.extractStringValueList(ctx, keyForError)
if !ok {
return ""
}
arguments := make([]any, len(values))
for i, s := range values {
arguments[i] = s
}
if operation == qbtypesv5.FilterOperatorNotIn {
return builder.NotIn(columnExpression, arguments...)
}
return builder.In(columnExpression, arguments...)
}
v.addError("operator %s on %q is not implemented", operationName(operation), keyForError)
return ""
}
func (v *visitor) buildTimestampComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
t, ok := v.extractSingleTimestampValue(ctx)
if !ok {
return ""
}
switch operation {
case qbtypesv5.FilterOperatorEqual:
return v.selectBuilder.Equal(columnExpression, t)
case qbtypesv5.FilterOperatorNotEqual:
return v.selectBuilder.NotEqual(columnExpression, t)
case qbtypesv5.FilterOperatorLessThan:
return v.selectBuilder.LessThan(columnExpression, t)
case qbtypesv5.FilterOperatorLessThanOrEq:
return v.selectBuilder.LessEqualThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThan:
return v.selectBuilder.GreaterThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return v.selectBuilder.GreaterEqualThan(columnExpression, t)
}
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
timestamps, ok := v.extractTwoTimestampValues(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotBetween {
return v.selectBuilder.NotBetween(columnExpression, timestamps[0], timestamps[1])
}
return v.selectBuilder.Between(columnExpression, timestamps[0], timestamps[1])
}
v.addError("operator %s on timestamp is not implemented", operationName(operation))
return ""
}
func (v *visitor) buildBoolComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
b, ok := v.extractSingleBoolValue(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotEqual {
return v.selectBuilder.NotEqual(columnExpression, b)
}
return v.selectBuilder.Equal(columnExpression, b)
}
func (v *visitor) buildTagComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
subqueryBuilder := sqlbuilder.NewSelectBuilder()
if operation == qbtypesv5.FilterOperatorExists || operation == qbtypesv5.FilterOperatorNotExists {
buildSubqueryForTagKey(subqueryBuilder, tagKey)
} else {
// All other tag operators take the positive form of the value predicate
// and toggle the EXISTS wrapper for negation. Inverse() flips Not<X> → <X>.
positiveOperation := operation
if operation.IsNegativeOperator() {
positiveOperation = operation.Inverse()
}
valuePredicate := v.buildStringOperation(subqueryBuilder, ctx, positiveOperation, "t.value", tagKey)
if valuePredicate == "" {
return ""
}
buildSubqueryForTagKeyAndValue(subqueryBuilder, tagKey, valuePredicate)
}
if operation.IsNegativeOperator() {
return v.selectBuilder.NotExists(subqueryBuilder)
}
return v.selectBuilder.Exists(subqueryBuilder)
}
func buildSubqueryForTagKey(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
"LOWER(t.key) = LOWER("+subqueryBuilder.Var(tagKey)+")",
)
}
func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey, valuePredicate string) *sqlbuilder.SelectBuilder {
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
}
// ─── value extraction helpers ───────────────────────────────────────────────
func (v *visitor) addError(format string, arguments ...any) {
v.errors = append(v.errors, fmt.Sprintf(format, arguments...))
}
func (v *visitor) extractSingleStringValue(ctx *grammar.ComparisonContext, keyForError string) (string, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected exactly one value for %q", keyForError)
return "", false
}
return v.extractStringValue(values[0], keyForError)
}
func (v *visitor) extractSingleBoolValue(ctx *grammar.ComparisonContext) (bool, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected a single boolean (true/false)")
return false, false
}
return v.extractBoolValue(values[0])
}
func (v *visitor) extractSingleTimestampValue(ctx *grammar.ComparisonContext) (time.Time, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected a single RFC3339 timestamp")
return time.Time{}, false
}
return v.extractTimestampValue(values[0])
}
func (v *visitor) extractTwoTimestampValues(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
values := ctx.AllValue()
if len(values) != 2 {
v.addError("BETWEEN expects two RFC3339 timestamps")
return [2]time.Time{}, false
}
a, ok1 := v.extractTimestampValue(values[0])
b, ok2 := v.extractTimestampValue(values[1])
if !ok1 || !ok2 {
return [2]time.Time{}, false
}
return [2]time.Time{a, b}, true
}
func (v *visitor) extractStringValueList(ctx *grammar.ComparisonContext, keyForError string) ([]string, bool) {
var valuesCtx []grammar.IValueContext
switch {
case ctx.InClause() != nil:
inClause := ctx.InClause()
if inClause.ValueList() != nil {
valuesCtx = inClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{inClause.Value()}
}
case ctx.NotInClause() != nil:
notInClause := ctx.NotInClause()
if notInClause.ValueList() != nil {
valuesCtx = notInClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{notInClause.Value()}
}
default:
v.addError("IN clause is missing for %q", keyForError)
return nil, false
}
if len(valuesCtx) == 0 {
v.addError("IN list for %q is empty", keyForError)
return nil, false
}
out := make([]string, 0, len(valuesCtx))
for _, valueContext := range valuesCtx {
s, ok := v.extractStringValue(valueContext, keyForError)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
func (v *visitor) extractStringValue(ctx grammar.IValueContext, keyForError string) (string, bool) {
if ctx.QUOTED_TEXT() != nil {
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
}
if ctx.KEY() != nil {
// Bare tokens are accepted as strings, mirroring the FilterQuery lexer's
// treatment of unquoted identifiers on the value side.
return ctx.KEY().GetText(), true
}
v.addError("expected a string value for %q, got %q", keyForError, ctx.GetText())
return "", false
}
func (v *visitor) extractBoolValue(ctx grammar.IValueContext) (bool, bool) {
if ctx.BOOL() == nil {
v.addError("expected a boolean (true/false), got %q", ctx.GetText())
return false, false
}
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
}
func (v *visitor) extractTimestampValue(ctx grammar.IValueContext) (time.Time, bool) {
if ctx.QUOTED_TEXT() == nil {
v.addError("expected an RFC3339 timestamp string, got %q", ctx.GetText())
return time.Time{}, false
}
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
v.addError("invalid RFC3339 timestamp %q: %s", raw, err.Error())
return time.Time{}, false
}
return t, true
}
// ─── operator spelling ───────────────────────────────────────────────────────
// operationName returns the user-facing spelling of a FilterOperator, used only in
// error messages — go-sqlbuilder's Cond helpers emit the SQL keywords.
func operationName(operation qbtypesv5.FilterOperator) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
return "="
case qbtypesv5.FilterOperatorNotEqual:
return "!="
case qbtypesv5.FilterOperatorLessThan:
return "<"
case qbtypesv5.FilterOperatorLessThanOrEq:
return "<="
case qbtypesv5.FilterOperatorGreaterThan:
return ">"
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return ">="
case qbtypesv5.FilterOperatorBetween:
return "BETWEEN"
case qbtypesv5.FilterOperatorNotBetween:
return "NOT BETWEEN"
case qbtypesv5.FilterOperatorLike:
return "LIKE"
case qbtypesv5.FilterOperatorNotLike:
return "NOT LIKE"
case qbtypesv5.FilterOperatorILike:
return "ILIKE"
case qbtypesv5.FilterOperatorNotILike:
return "NOT ILIKE"
case qbtypesv5.FilterOperatorContains:
return "CONTAINS"
case qbtypesv5.FilterOperatorNotContains:
return "NOT CONTAINS"
case qbtypesv5.FilterOperatorRegexp:
return "REGEXP"
case qbtypesv5.FilterOperatorNotRegexp:
return "NOT REGEXP"
case qbtypesv5.FilterOperatorIn:
return "IN"
case qbtypesv5.FilterOperatorNotIn:
return "NOT IN"
case qbtypesv5.FilterOperatorExists:
return "EXISTS"
case qbtypesv5.FilterOperatorNotExists:
return "NOT EXISTS"
}
return "?"
}
func trimQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
s = s[1 : len(s)-1]
}
}
s = strings.ReplaceAll(s, `\\`, `\`)
s = strings.ReplaceAll(s, `\'`, `'`)
return s
}

View File

@@ -2,7 +2,6 @@ package impldashboard
import (
"context"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -64,155 +63,6 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
// spec calls for. Aliases:
//
// dashboard — the visitor expects this
// user_dashboard_preference AS preference — only used inside this query
//
// Sort is "is_pinned DESC, <sort> <order>" so pinned dashboards float to the
// top inside the requested ordering. Name-sort goes through the same
// JSONExtractString path the visitor uses for name/description filtering.
func (store *store) ListForUser(
ctx context.Context,
orgID valuer.UUID,
userID valuer.UUID,
params *dashboardtypes.ListDashboardsV2Params,
) ([]*dashboardtypes.StorableDashboardWithPinInfo, int64, error) {
compiled, err := Compile(params.Query, store.sqlstore.Formatter())
if err != nil {
return nil, 0, err
}
type listedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
IsPinned bool `bun:"is_pinned"`
Total int64 `bun:"total"`
}
rows := make([]*listedRow, 0)
q := store.sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.name, dashboard.data, dashboard.locked, dashboard.source, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("CASE WHEN preference.is_pinned THEN 1 ELSE 0 END AS is_pinned").
ColumnExpr("COUNT(*) OVER () AS total").
Join("LEFT JOIN user_dashboard_preference AS preference ON preference.user_id = ? AND preference.dashboard_id = dashboard.id", userID).
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
sortExpr, err := store.sortExprForListV2(params.Sort)
if err != nil {
return nil, 0, err
}
q = q.
OrderExpr("is_pinned DESC").
OrderExpr(sortExpr + " " + strings.ToUpper(params.Order.StringValue())).
Limit(params.Limit).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list dashboards")
}
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the
// full filter total. Empty result page => zero matches.
var total int64
if len(rows) > 0 {
total = rows[0].Total
}
out := make([]*dashboardtypes.StorableDashboardWithPinInfo, len(rows))
for i, r := range rows {
out[i] = &dashboardtypes.StorableDashboardWithPinInfo{
Dashboard: r.StorableDashboard,
Pinned: r.IsPinned,
}
}
return out, total, nil
}
// ListV2 is the pure (user-independent) list: the same filter/sort/pagination as
// ListForUser, but without the per-user pin join or pin-first ordering.
func (store *store) ListV2(
ctx context.Context,
orgID valuer.UUID,
params *dashboardtypes.ListDashboardsV2Params,
) ([]*dashboardtypes.StorableDashboard, int64, error) {
compiled, err := Compile(params.Query, store.sqlstore.Formatter())
if err != nil {
return nil, 0, err
}
type listedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
Total int64 `bun:"total"`
}
rows := make([]*listedRow, 0)
q := store.sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.name, dashboard.data, dashboard.locked, dashboard.source, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("COUNT(*) OVER () AS total").
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
sortExpr, err := store.sortExprForListV2(params.Sort)
if err != nil {
return nil, 0, err
}
q = q.
OrderExpr(sortExpr + " " + strings.ToUpper(params.Order.StringValue())).
Limit(params.Limit).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list dashboards")
}
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the
// full filter total. Empty result page => zero matches.
var total int64
if len(rows) > 0 {
total = rows[0].Total
}
out := make([]*dashboardtypes.StorableDashboard, len(rows))
for i, r := range rows {
out[i] = r.StorableDashboard
}
return out, total, nil
}
// sortExprForListV2 maps a sort enum to the SQL expression to plug into
// ORDER BY. Title-sort routes through the SQLFormatter so it stays
// dialect-aware (matches what the filter visitor does for the name filter).
func (store *store) sortExprForListV2(sort dashboardtypes.ListSort) (string, error) {
switch sort {
case dashboardtypes.ListSortUpdatedAt:
return "dashboard.updated_at", nil
case dashboardtypes.ListSortCreatedAt:
return "dashboard.created_at", nil
case dashboardtypes.ListSortName:
return string(store.sqlstore.Formatter().JSONExtractString("dashboard.data", "$.spec.display.name")), nil
}
return "", errors.Newf(errors.TypeInvalidInput, dashboardtypes.ErrCodeDashboardListInvalid,
"unsupported sort field %q", sort)
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.
@@ -367,108 +217,3 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
return cb(ctx)
})
}
// PinForUser combines the count check, the existence check, and the upsert in
// a single statement so the limit gate and the insert can't drift between two
// round-trips. The count and existence checks gate on is_pinned = true so they
// stay correct once the row carries preferences other than the pin.
//
// pin exists? | pinned count < 10? | WHERE passes? | effect | rows
// ------------|--------------------|-------------------------|-------------------------------------|-----
// no | yes | yes (count branch) | INSERT new pinned row | 1
// no | no | no | nothing (limit hit) | 0
// yes | yes | yes (count branch) | INSERT → conflict → UPDATE is_pinned| 1
// yes | no | yes (EXISTS OR branch) | INSERT → conflict → UPDATE is_pinned| 1
//
// rows = 0 is the only signal of a real limit hit.
func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.UserDashboardPreference) error {
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
INSERT INTO user_dashboard_preference (id, user_id, dashboard_id, is_pinned, created_at, updated_at)
SELECT ?, ?, ?, true, ?, ?
WHERE (SELECT COUNT(*) FROM user_dashboard_preference WHERE user_id = ? AND is_pinned = true) < ?
OR EXISTS (SELECT 1 FROM user_dashboard_preference WHERE user_id = ? AND dashboard_id = ? AND is_pinned = true)
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true, updated_at = ?
`,
preference.ID, preference.UserID, preference.DashboardID, preference.CreatedAt, preference.UpdatedAt,
preference.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
preference.UserID, preference.DashboardID,
preference.UpdatedAt,
).Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't pin dashboard for user")
}
rows, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read pin result")
}
if rows == 0 {
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePinnedDashboardLimitHit,
"cannot pin more than %d dashboards", dashboardtypes.MaxPinnedDashboardsPerUser)
}
return nil
}
// UnpinForUser deletes the user's preference row. This is fine while is_pinned
// is the only preference stored; once the row carries other preferences this
// must become an UPDATE that clears is_pinned instead of dropping the row.
func (store *store) UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't unpin dashboard for user")
}
return nil
}
func (store *store) DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
}
return nil
}
func (store *store) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
userIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("users").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("user_id IN (?)", userIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
}
return nil
}

View File

@@ -42,69 +42,6 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusCreated, dashboard.ToGettableDashboardV2())
}
func (handler *handler) ListV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
params := new(dashboardtypes.ListDashboardsV2Params)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
out, err := handler.module.ListV2(ctx, orgID, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (handler *handler) ListForUserV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
userID := valuer.MustNewUUID(claims.IdentityID())
params := new(dashboardtypes.ListDashboardsV2Params)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
out, err := handler.module.ListForUserV2(ctx, orgID, userID, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -268,79 +205,3 @@ func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, true)
}
func (handler *handler) UnpinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, false)
}
func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
userID := valuer.MustNewUUID(claims.IdentityID())
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
if pin {
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
} else {
err = handler.module.UnpinV2(ctx, orgID, userID, dashboardID)
}
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
if err := handler.module.DeleteV2(ctx, orgID, dashboardID); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -43,58 +42,6 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
return dashboard, nil
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
dashboards, total, err := module.store.ListV2(ctx, orgID, params)
if err != nil {
return nil, err
}
dashboardIDs := make([]valuer.UUID, len(dashboards))
for i, d := range dashboards {
dashboardIDs[i] = d.ID
}
tagsByDashboard, allTags, err := module.fetchDashboardTags(ctx, orgID, dashboardIDs)
if err != nil {
return nil, err
}
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags)
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
rows, total, err := module.store.ListForUser(ctx, orgID, userID, params)
if err != nil {
return nil, err
}
dashboardIDs := make([]valuer.UUID, len(rows))
for i, r := range rows {
dashboardIDs[i] = r.Dashboard.ID
}
tagsByDashboard, allTags, err := module.fetchDashboardTags(ctx, orgID, dashboardIDs)
if err != nil {
return nil, err
}
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags)
}
func (module *module) fetchDashboardTags(ctx context.Context, orgID valuer.UUID, dashboardIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, []*tagtypes.Tag, error) {
tagsByDashboard, err := module.tagModule.ListForResources(ctx, orgID, coretypes.KindDashboard, dashboardIDs)
if err != nil {
return nil, nil, err
}
allTags, err := module.tagModule.List(ctx, orgID, coretypes.KindDashboard)
if err != nil {
return nil, nil, err
}
return tagsByDashboard, allTags, nil
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.Get(ctx, orgID, id)
if err != nil {
@@ -119,7 +66,7 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.ErrIfNotUpdatable(); err != nil {
if err := existing.CanUpdate(); err != nil {
return nil, err
}
@@ -154,7 +101,7 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.ErrIfNotUpdatable(); err != nil {
if err := existing.CanUpdate(); err != nil {
return nil, err
}
@@ -188,27 +135,6 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return existing, nil
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.ErrIfNotDeletable(); err != nil {
return err
}
return module.store.RunInTx(ctx, func(ctx context.Context) error {
// Syncing to an empty tag set drops every tag link for the dashboard.
if _, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, nil); err != nil {
return err
}
if err := module.store.DeletePreferencesForDashboard(ctx, orgID, id); err != nil {
return err
}
return module.store.Delete(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
@@ -223,18 +149,3 @@ func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id va
}
return module.store.Update(ctx, orgID, storable)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
if _, err := module.GetV2(ctx, orgID, id); err != nil {
return err
}
return module.store.PinForUser(ctx, dashboardtypes.NewUserDashboardPreference(userID, id))
}
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
}

View File

@@ -67,10 +67,6 @@ func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, ki
})
}
func (m *module) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error) {
return m.store.List(ctx, orgID, kind)
}
func (m *module) ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
return m.store.ListByResource(ctx, orgID, kind, resourceID)
}

View File

@@ -13,9 +13,6 @@ type Module interface {
// and reconciles the resource's links to exactly that set, all in one transaction.
SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error)
// List returns every tag of the given kind in the org.
List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error)
ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error)
// Resources with no tags are absent from the returned map.

View File

@@ -34,11 +34,10 @@ type setter struct {
analytics analytics.Analytics
config root.Config
getter root.Getter
onDeleteUser []root.OnDeleteUser
}
// This module is a WIP, don't take inspiration from this.
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, onDeleteUser []root.OnDeleteUser) root.Setter {
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter) root.Setter {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &setter{
store: store,
@@ -51,7 +50,6 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
authz: authz,
config: config,
getter: getter,
onDeleteUser: onDeleteUser,
}
}
@@ -408,12 +406,6 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
for _, onDeleteUser := range module.onDeleteUser {
if err := onDeleteUser(ctx, orgID, user.ID); err != nil {
return err
}
}
traitsOrProperties := types.NewTraitsFromUser(user)
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Deleted", map[string]any{

View File

@@ -129,6 +129,3 @@ type Handler interface {
ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)
}
// OnDeleteUser lets other modules clean up data tied to a deleted user.
type OnDeleteUser func(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error

View File

@@ -1,33 +0,0 @@
package filterquery
import (
"fmt"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/antlr4-go/antlr/v4"
)
func Parse(query string) (antlr.ParseTree, *antlr.CommonTokenStream, *ErrorCollector) {
collector := NewErrorCollector()
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
lexer.AddErrorListener(collector)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(collector)
return parser.Query(), tokens, collector
}
type ErrorCollector struct {
*antlr.DefaultErrorListener
Errors []string
}
func NewErrorCollector() *ErrorCollector {
return &ErrorCollector{}
}
func (c *ErrorCollector) SyntaxError(_ antlr.Recognizer, _ any, line, column int, msg string, _ antlr.RecognitionException) {
c.Errors = append(c.Errors, fmt.Sprintf("syntax error at %d:%d — %s", line, column, msg))
}

View File

@@ -0,0 +1,94 @@
package clickhouseprometheus
import (
"context"
"sync"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
)
// statementRecorder collects the ClickHouse statements a PromQL evaluation would
// run. It is safe for concurrent use because the Prometheus engine may evaluate
// (and therefore Select) multiple selectors concurrently.
type statementRecorder struct {
mu sync.Mutex
statements []prometheus.CapturedStatement
}
func (r *statementRecorder) record(query string, args []any) {
r.mu.Lock()
defer r.mu.Unlock()
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
}
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]prometheus.CapturedStatement, len(r.statements))
copy(out, r.statements)
return out
}
// captureClient is a remote.ReadClient that builds the same ClickHouse SQL as
// the real client but records it instead of executing, returning an empty
// result so the engine completes without touching ClickHouse. It records the
// self-contained samples query per selector (which embeds the series-selection
// subquery), so the recorded statement reflects the actual data read.
type captureClient struct {
*client
recorder *statementRecorder
}
func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (storage.SeriesSet, error) {
// Raw-SQL passthrough ({job="rawsql", query="..."}): record the raw query.
if len(query.Matchers) == 2 {
var hasJob bool
var queryString string
for _, m := range query.Matchers {
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
hasJob = true
}
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
queryString = m.Value
}
}
if hasJob && queryString != "" {
c.recorder.record(queryString, nil)
return storage.EmptySeriesSet(), nil
}
}
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
// Build the series-selection subquery and the self-contained samples query
// exactly as the executing path would, but only record them.
subQuery, args, err := c.client.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil
}
// captureQueryable adapts the capturing read client to storage.Queryable,
// mirroring how the real provider wraps its querier.
type captureQueryable struct {
inner storage.SampleAndChunkQueryable
}
func (c captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
querier, err := c.inner.Querier(mint, maxt)
if err != nil {
return nil, err
}
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}

View File

@@ -204,8 +204,11 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
return fingerprints, nil
}
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
// buildSamplesQuery renders the samples SQL (and its args) that fetches the
// data points for the series selected by subQuery. It embeds the series
// selection as a subquery, so the returned statement is self-contained — the
// dry-run/preview path renders it without executing.
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
argCount := len(args)
query := fmt.Sprintf(`
@@ -217,6 +220,13 @@ func (client *client) querySamples(ctx context.Context, start int64, end int64,
allArgs := append([]any{metricName}, args...)
allArgs = append(allArgs, start, end)
return query, allArgs
}
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
if err != nil {

View File

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

View File

@@ -64,3 +64,17 @@ func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}
// CapturingStorage implements prometheus.StatementCapturer: it returns a Storage
// that records the ClickHouse SQL each selector would run (without executing
// it) and a recorder to read the captured statements back. A fresh recorder is
// created per call so concurrent dry-runs don't share state.
func (provider *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
recorder := &statementRecorder{}
capture := &captureClient{
client: &client{settings: provider.settings, telemetryStore: provider.telemetryStore},
recorder: recorder,
}
queryable := remote.NewSampleAndChunkQueryableClient(capture, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
return captureQueryable{inner: queryable}, recorder
}

View File

@@ -15,3 +15,25 @@ type Prometheus interface {
Storage() storage.Queryable
Parser() Parser
}
// CapturedStatement is one underlying datastore statement that a PromQL query would
// run, captured without executing it.
type CapturedStatement struct {
Query string
Args []any
}
// StatementRecorder collects the Statements captured while a PromQL query is
// evaluated against a capturing Storage (see StatementCapturer).
type StatementRecorder interface {
Statements() []CapturedStatement
}
// StatementCapturer is an optional capability of a Prometheus provider: it
// returns a Storage that records the datastore statement(s) each Select would
// run — without executing them — together with a recorder to read them back.
// The query dry-run path discovers it via a type assertion, so providers that
// do not implement it simply expose no underlying SQL.
type StatementCapturer interface {
CapturingStorage() (storage.Queryable, StatementRecorder)
}

View File

@@ -72,6 +72,70 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, queryRangeResponse)
}
// QueryRangePreview is the dry-run counterpart of QueryRange. It accepts the
// same payload, validates and renders the underlying SQL/PromQL for each query
// without executing it, and returns the per-query statements. When the
// ?explain= query parameter selects a ClickHouse EXPLAIN variant, the rendered
// SQL is EXPLAIN-ed against the telemetry store and the output is attached to
// each statement.
func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "querier",
instrumentationtypes.CodeFunctionName: "QueryRangePreview",
})
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
var queryRangeRequest qbtypes.QueryRangeRequest
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
// NB: validation is intentionally NOT done here. QueryRangePreview checks
// request-level invariants (aborting on failure) and validates each query's
// spec individually, reporting per-query structural errors in the response
// instead of failing fast on the first one — the point of the dry-run.
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
explain, err := ParseExplainVariant(req.URL.Query().Get("explain"))
if err != nil {
render.Error(rw, err)
return
}
verbose, err := ParseVerbose(req.URL.Query().Get("verbose"))
if err != nil {
render.Error(rw, err)
return
}
score, err := ParseScore(req.URL.Query().Get("score"))
if err != nil {
render.Error(rw, err)
return
}
preview, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, qbtypes.QueryRangePreviewOptions{Explain: explain, Verbose: verbose, IncludeGranuleSkipScore: score})
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, preview)
}
func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -194,6 +194,12 @@ func (q *builderQuery[T]) isWindowList() bool {
return true
}
// Statement renders the SQL statement for the builder query without executing
// it. It is used by the dry-run/preview path.
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
}
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
// can we do window based pagination?

View File

@@ -99,6 +99,16 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
return newQuery.String(), nil
}
// Statement renders the SQL statement for the ClickHouse SQL query without
// executing it. It is used by the dry-run/preview path.
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
if err != nil {
return nil, err
}
return &qbtypes.Statement{Query: rendered, Args: q.args}, nil
}
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),

View File

@@ -12,6 +12,11 @@ import (
type Querier interface {
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
// QueryRangePreview validates and renders the queries in req without
// executing them. opts controls dry-run behavior such as which
// EXPLAIN variant to attach to the response; the zero value performs
// a validation-only preview with no EXPLAIN.
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
}
// BucketCache is the interface for bucket-based caching.
@@ -24,6 +29,10 @@ type BucketCache interface {
type Handler interface {
QueryRange(rw http.ResponseWriter, req *http.Request)
// QueryRangePreview is the dry-run endpoint: it validates and renders the
// queries without executing them, optionally attaching ClickHouse EXPLAIN
// output selected by the ?explain= query parameter.
QueryRangePreview(rw http.ResponseWriter, req *http.Request)
QueryRawStream(rw http.ResponseWriter, req *http.Request)
ReplaceVariables(rw http.ResponseWriter, req *http.Request)
}

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

@@ -0,0 +1,694 @@
package querier
import (
"context"
"encoding/json"
"fmt"
"math"
"reflect"
"slices"
"strings"
"sync"
chproto "github.com/ClickHouse/ch-go/proto"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// statementProvider is implemented by query types that can render the
// underlying SQL/PromQL statement without executing it.
type statementProvider interface {
Statement(ctx context.Context) (*qbtypes.Statement, error)
}
// missingMetricNames returns the distinct metric names referenced by a metric
// builder query, in order of first appearance. It is used to name the metric(s)
// in the warning attached to a fully-missing-metric query. Returns nil for any
// non-metric query.
func missingMetricNames(env qbtypes.QueryEnvelope) []string {
spec, ok := env.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
if !ok {
return nil
}
names := make([]string, 0, len(spec.Aggregations))
for _, agg := range spec.Aggregations {
if agg.MetricName != "" && !slices.Contains(names, agg.MetricName) {
names = append(names, agg.MetricName)
}
}
return names
}
// clickhouseExplainClause maps a variant to the EXPLAIN clause understood
// by ClickHouse (i.e. what comes between EXPLAIN and the SELECT).
func clickhouseExplainClause(v qbtypes.ExplainVariant) (string, bool) {
switch v {
case qbtypes.ExplainVariantPlan:
return "PLAN", true
case qbtypes.ExplainVariantEstimate:
return "ESTIMATE", true
default:
return "", false
}
}
// ParseExplainVariant parses the ?explain= query parameter. An empty value
// (or "false") returns ExplainVariantNone. The literal "true" maps to PLAN
// for back-compat with simple ?explain=true. Otherwise the value must
// match one of the named variants.
func ParseExplainVariant(value string) (qbtypes.ExplainVariant, error) {
token := strings.ToLower(strings.TrimSpace(value))
switch token {
case "", "false":
return qbtypes.ExplainVariantNone, nil
case "true":
return qbtypes.ExplainVariantPlan, nil
}
v := qbtypes.ExplainVariant(token)
if _, ok := clickhouseExplainClause(v); !ok {
return qbtypes.ExplainVariantNone, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported explain variant %q (allowed: plan, estimate)", token)
}
return v, nil
}
// parseBoolQueryParam parses a true/false query parameter. An empty value (or
// "false"/"0") is false; "true"/"1" is true. name is used only in the error
// message so each caller reports the parameter the user actually sent.
func parseBoolQueryParam(value, name string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "false", "0":
return false, nil
case "true", "1":
return true, nil
}
return false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid %s value %q (allowed: true, false)", name, value)
}
// ParseVerbose parses the ?verbose= query parameter. When true the preview
// includes the rendered ClickHouse statement(s); the default is a lightweight
// verdict-only preview (valid/error/warnings per query).
func ParseVerbose(value string) (bool, error) {
return parseBoolQueryParam(value, "verbose")
}
// ParseScore parses the ?score= query parameter. It defaults to TRUE: the
// top-level granuleSkipScore is computed unless explicitly disabled with
// score=false (which skips the granule-skip EXPLAIN round trips).
func ParseScore(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "true", "1":
return true, nil
case "false", "0":
return false, nil
}
return false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid score value %q (allowed: true, false)", value)
}
// QueryRangePreview validates each query in the composite query without
// executing it. By default it returns a lightweight per-query verdict
// (valid/error/warnings) plus the headline GranuleSkipScore (unless disabled via
// opts.IncludeGranuleSkipScore=false). When opts.Verbose (or Explain, which
// implies it) is set, it also renders the underlying ClickHouse statement(s)
// each query would run, with opts.Explain attaching EXPLAIN output and the
// per-statement GranuleSkipScore alongside each.
func (q *querier) QueryRangePreview(
ctx context.Context,
_ valuer.UUID,
req *qbtypes.QueryRangeRequest,
opts qbtypes.QueryRangePreviewOptions,
) (*qbtypes.QueryRangePreviewResponse, error) {
// The preview must transform the payload exactly as QueryRange does so the
// rendered SQL matches what the same payload will actually execute. Coerce
// the window to epoch milliseconds up front, just like QueryRange.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
// Validate request-level invariants (time range, request type, unique
// names, …) up front — these are request-wide, so there is nothing per
// query to preview if they fail. Per-query spec validation is deliberately
// NOT done here: it runs per query below so each query's structural error is
// reported in its own QueryPreview instead of aborting the whole
// preview on the first one. validationOpts carries the request-type-specific
// options into that per-query validation.
validationOpts, err := req.ValidateRequestScope()
if err != nil {
return nil, err
}
// A query that only exists as a dependency of a trace operator (e.g. A and
// B in C := A => B) is not executed standalone, so it gets no statement of
// its own — matching QueryRange.
dependencyQueries, err := q.constructTraceOperatorDependencyMap(req.CompositeQuery.Queries)
if err != nil {
return nil, err
}
results := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
// Phase 1: normalize every query's spec (step interval + metric metadata)
// and capture the per-query warnings/errors. This runs for ALL queries —
// including trace-operator dependencies — before any statement is rendered,
// because a trace-operator query reads its siblings' specs at render time
// and they must already be normalized. adjustStepInterval and
// resolveMetricMetadata both patch the spec in place, so feed each a
// single-element slice and write the patched envelope back into the
// composite query. Doing it per-query (rather than once over all queries
// like QueryRange) lets us attribute each warning/error to the query that
// produced it, which is the whole point of a per-query preview report; the
// extra metadata lookups are acceptable on this low-volume dry-run path.
prepared := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
missingMetricQuerySet := make(map[string]bool)
for idx := range req.CompositeQuery.Queries {
name := req.CompositeQuery.Queries[idx].GetQueryName()
ps := qbtypes.QueryPreview{}
// Validate this query's spec on its own and attribute any structural
// error to it, instead of aborting the whole preview on the first bad
// query (the request-level invariants were already checked above). An
// invalid spec gets no step/metadata normalization or rendering.
if vErr := req.CompositeQuery.Queries[idx].Validate(validationOpts...); vErr != nil {
ps.Error = vErr
prepared[name] = ps
continue
}
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
ps.Warnings = q.adjustStepInterval(env, req.Start, req.End)
missingMetricQueries, dormantMetricsWarningMsg, mErr := q.resolveMetricMetadata(ctx, env, req.Start, req.End)
if mErr != nil {
// Don't abort the whole preview: report this query's error and keep
// going so the agent sees every problem in one round trip.
ps.Error = mErr
} else {
if dormantMetricsWarningMsg != "" {
ps.Warnings = append(ps.Warnings, dormantMetricsWarningMsg)
}
if len(missingMetricQueries) > 0 {
missingMetricQuerySet[name] = true
// A fully-missing-metric query renders no SQL and returns an empty
// result, so flag it explicitly. resolveMetricMetadata only emits a
// (dormant) warning for external metrics it has seen before; when it
// stays silent — e.g. internal signoz.* metrics — the empty result
// would otherwise be unexplained, so attach a clear note naming the
// metric(s) the agent referenced.
if dormantMetricsWarningMsg == "" {
if metricNames := missingMetricNames(env[0]); len(metricNames) > 0 {
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
"query %q references metric(s) %s with no data available; it will return an empty result",
name, strings.Join(metricNames, ", ")))
}
}
}
}
req.CompositeQuery.Queries[idx] = env[0]
prepared[name] = ps
}
// Phase 2: render the statement for each query that actually executes, and
// collect the ClickHouse-bound work (granuleSkipScore/EXPLAIN) to run concurrently.
var previewTasks []previewTask
for _, query := range req.CompositeQuery.Queries {
name := query.GetQueryName()
if query.GetType() != qbtypes.QueryTypeTraceOperator && dependencyQueries[name] {
continue
}
ps := prepared[name]
// Surface a phase-1 error (e.g. a not-found metric) without rendering.
if ps.Error != nil {
results[name] = ps
continue
}
// Every aggregation resolved to a missing metric: QueryRange returns an
// empty result for this query and renders no SQL. Mirror that.
if missingMetricQuerySet[name] {
results[name] = ps
continue
}
var provider qbtypes.Query
switch query.Type {
case qbtypes.QueryTypePromQL:
promQuery, ok := query.Spec.(qbtypes.PromQuery)
if !ok {
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
results[name] = ps
continue
}
provider = newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
case qbtypes.QueryTypeClickHouseSQL:
chQuery, ok := query.Spec.(qbtypes.ClickHouseQuery)
if !ok {
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec)
results[name] = ps
continue
}
provider = newchSQLQuery(q.logger, q.telemetryStore, chQuery, nil, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
case qbtypes.QueryTypeTraceOperator:
traceOpQuery, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", query.Spec)
results[name] = ps
continue
}
provider = &traceOperatorQuery{
telemetryStore: q.telemetryStore,
stmtBuilder: q.traceOperatorStmtBuilder,
spec: traceOpQuery,
compositeQuery: &req.CompositeQuery,
fromMS: uint64(req.Start),
toMS: uint64(req.End),
kind: req.RequestType,
}
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
provider = newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
stmtBuilder := q.logStmtBuilder
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
provider = newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars)
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
if spec.Source == telemetrytypes.SourceMeter {
provider = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
} else {
provider = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
}
default:
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec)
results[name] = ps
continue
}
default:
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query type %q", query.Type)
results[name] = ps
continue
}
stmtProvider, ok := provider.(statementProvider)
if !ok {
ps.Error = errors.NewInternalf(errors.CodeInternal, "query does not support preview")
results[name] = ps
continue
}
// Build the statement even in validate-only mode: a successful build is
// the strongest validation we can do (it parses the filter/group-by and
// resolves fields against the schema), and a build error is exactly the
// per-query verdict a validation caller wants.
stmt, sErr := stmtProvider.Statement(ctx)
if sErr != nil {
ps.Error = sErr
results[name] = ps
continue
}
ps.Warnings = append(ps.Warnings, stmt.Warnings...)
// clickhouse_sql is user-authored raw SQL; rendering only substitutes
// variables, so by itself it doesn't prove the SQL is valid. Verify it
// parses and binds (tables/columns/types resolve) via EXPLAIN PLAN —
// without executing. Builder/PromQL/trace-operator SQL is engine-generated
// and well-formed by construction, so this is scoped to clickhouse_sql.
if query.Type == qbtypes.QueryTypeClickHouseSQL {
if invalidErr, infraErr := q.explainBindCheck(ctx, stmt.Query, stmt.Args); invalidErr != nil {
ps.Error = invalidErr
results[name] = ps
continue
} else if infraErr != nil {
ps.Warnings = append(ps.Warnings, "could not validate ClickHouse SQL: "+infraErr.Error())
}
}
// The query is fully validated by this point (statement built, plus the
// clickhouse_sql bind check). Render the underlying statement(s) when the
// caller wants them (verbose/explain) or when we need them to compute the
// top-level granuleSkipScore (on by default). If none of those apply
// (score disabled and not verbose), return just the verdict.
needScore := opts.IncludeGranuleSkipScore
needExplain := opts.Explain != qbtypes.ExplainVariantNone
if !opts.Verbose && !needExplain && !needScore {
results[name] = ps
continue
}
// Every query exposes its underlying ClickHouse statement(s) uniformly in
// Statements. Builder/ClickHouse/trace-operator render exactly one; PromQL
// is not SQL — the Prometheus engine issues one statement per metric
// selector, captured (without executing) via PreviewStatements.
if query.Type == qbtypes.QueryTypePromQL {
if pq, ok := provider.(*promqlQuery); ok {
sqlStmts, pErr := pq.PreviewStatements(ctx)
if pErr != nil {
ps.Warnings = append(ps.Warnings, "could not render underlying ClickHouse SQL: "+pErr.Error())
} else {
for _, s := range sqlStmts {
ps.Statements = append(ps.Statements, qbtypes.PreviewStatement{Query: s.Query, Args: s.Args})
}
}
}
} else {
ps.Statements = []qbtypes.PreviewStatement{{Query: stmt.Query, Args: stmt.Args}}
}
results[name] = ps
// granuleSkipScore and EXPLAIN both hit ClickHouse. Queue one task per
// statement; runPreviewTasks executes them concurrently across queries
// after rendering, rather than serializing one query's round trips behind
// the next.
if needScore || needExplain {
for j := range ps.Statements {
previewTasks = append(previewTasks, previewTask{name: name, stmtIdx: j, query: ps.Statements[j].Query, args: ps.Statements[j].Args})
}
}
}
q.runPreviewTasks(ctx, previewTasks, opts, results)
// granuleSkipScore is on by default, but the rendered statements are only
// returned when the caller asked (verbose/explain). So derive the headline
// per-query score from the statements (the minimum — the least-selective,
// worst-skipping statement, which dominates cost), then drop the statements
// from the response unless they were requested.
includeStatements := opts.Verbose || opts.Explain != qbtypes.ExplainVariantNone
for name, ps := range results {
var minScore *float64
for i := range ps.Statements {
s := ps.Statements[i].GranuleSkipScore
if s != nil && (minScore == nil || *s < *minScore) {
minScore = s
}
}
if minScore != nil {
v := *minScore // copy so the top-level field doesn't alias a statement entry
ps.Score = &v
}
if !includeStatements {
ps.Statements = nil
}
results[name] = ps
}
return &qbtypes.QueryRangePreviewResponse{
Queries: results,
}, nil
}
// previewTask is one rendered ClickHouse statement queued for ClickHouse-bound
// preview work (granuleSkipScore and/or EXPLAIN). stmtIdx is the index into the
// query's Statements list that this task's results merge back into.
type previewTask struct {
name string
stmtIdx int
query string
args []any
}
// runPreviewTasks computes the granuleSkipScore and/or EXPLAIN output for each task
// concurrently — every query's ClickHouse round trips are in flight at once
// instead of serialized — and merges the outcomes back into previews. A
// composite query holds only a handful of queries, so a goroutine per task is
// fine without an explicit concurrency bound. Each goroutine writes to its own
// slot; the merge into the previews map happens after the wait, single-
// threaded, so there are no map races.
func (q *querier) runPreviewTasks(ctx context.Context, tasks []previewTask, opts qbtypes.QueryRangePreviewOptions, previews map[string]qbtypes.QueryPreview) {
if len(tasks) == 0 {
return
}
type outcome struct {
score *float64
explain string
warnings []string
}
outcomes := make([]outcome, len(tasks))
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
t := tasks[i]
var out outcome
if opts.IncludeGranuleSkipScore {
if score, scErr := q.computeGranuleSkipScore(ctx, t.query, t.args); scErr != nil {
// Surface the failure instead of silently dropping the score.
out.warnings = append(out.warnings, "could not compute query score: "+scErr.Error())
} else if score != nil {
out.score = score
}
}
if opts.Explain != qbtypes.ExplainVariantNone {
if explained, eErr := q.runExplain(ctx, opts.Explain, t.query, t.args); eErr != nil {
// Surface the failure instead of silently dropping the output.
out.warnings = append(out.warnings, "could not run EXPLAIN: "+eErr.Error())
} else {
out.explain = explained
}
}
outcomes[i] = out
}(i)
}
wg.Wait()
for i := range tasks {
ps := previews[tasks[i].name]
if idx := tasks[i].stmtIdx; idx >= 0 && idx < len(ps.Statements) {
if outcomes[i].score != nil {
ps.Statements[idx].GranuleSkipScore = outcomes[i].score
}
if outcomes[i].explain != "" {
ps.Statements[idx].Explain = outcomes[i].explain
}
}
ps.Warnings = append(ps.Warnings, outcomes[i].warnings...)
previews[tasks[i].name] = ps
}
}
// runExplain runs `EXPLAIN <variant> <stmt>` against the telemetry store and
// returns the formatted output as a single string with one row per line.
//
// The column shape differs by variant: most variants (plan, ast, syntax,
// pipeline, query_tree) return a single `explain` String column, but ESTIMATE
// returns five columns (database, table, parts, rows, marks). So the scan is
// driven by the result's column types — each row's columns are scanned into
// destinations of the driver-reported types and tab-joined — rather than
// assuming a single string (which silently dropped ESTIMATE output before).
func (q *querier) runExplain(ctx context.Context, variant qbtypes.ExplainVariant, stmt string, args []any) (string, error) {
clause, ok := clickhouseExplainClause(variant)
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported explain variant %q", string(variant))
}
explainQuery := "EXPLAIN " + clause + " " + stmt
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, explainQuery, args...)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN %s", clause)
}
defer rows.Close()
colTypes := rows.ColumnTypes()
multiColumn := len(colTypes) > 1
var lines []string
// For a multi-column variant (ESTIMATE), lead with a header row so the
// tab-separated values are readable; single-column variants stay verbatim.
if multiColumn {
header := make([]string, len(colTypes))
for i, ct := range colTypes {
header[i] = ct.Name()
}
lines = append(lines, strings.Join(header, "\t"))
}
for rows.Next() {
dest := make([]any, len(colTypes))
for i, ct := range colTypes {
dest[i] = reflect.New(ct.ScanType()).Interface()
}
if err := rows.Scan(dest...); err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN row")
}
fields := make([]string, len(dest))
for i := range dest {
fields[i] = fmt.Sprintf("%v", reflect.ValueOf(dest[i]).Elem().Interface())
}
lines = append(lines, strings.Join(fields, "\t"))
}
if err := rows.Err(); err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN row iteration failed")
}
return strings.Join(lines, "\n"), nil
}
// userFacingClickHouseErrorCodes mirrors PR #10679's userFacingCHCodes: the
// ClickHouse error codes that indicate a problem with the query itself (bad SQL,
// unknown table/column, …) rather than a server-side/infra failure — i.e. the
// ones that should map to invalid input (400) instead of internal (500).
//
// TODO(#10679): once that PR lands, delete this and have explainBindCheck call
// the shared querier.mapClickHouseError so there's a single source of truth.
var userFacingClickHouseErrorCodes = map[chproto.Error]bool{
chproto.ErrSyntaxError: true,
chproto.ErrUnknownTable: true,
chproto.ErrUnknownDatabase: true,
chproto.ErrUnknownIdentifier: true,
chproto.ErrUnknownFunction: true,
chproto.ErrUnknownAggregateFunction: true,
chproto.ErrUnknownType: true,
chproto.ErrUnknownStorage: true,
chproto.ErrUnknownElementInAst: true,
chproto.ErrUnknownTypeOfQuery: true,
chproto.ErrIllegalTypeOfArgument: true,
chproto.ErrIllegalColumn: true,
chproto.ErrNumberOfArgumentsDoesntMatch: true,
chproto.ErrTooManyArgumentsForFunction: true,
chproto.ErrTooLessArgumentsForFunction: true,
}
// explainBindCheck validates that a rendered ClickHouse statement parses and
// binds (its tables, columns, and types resolve) by running EXPLAIN PLAN
// against it without executing it. It distinguishes two failure modes:
//
// - invalidErr (non-nil): ClickHouse rejected the statement with a user-facing
// error code — it's genuinely invalid input (syntax, unknown table/column,
// type mismatch). The caller marks the query invalid.
// - infraErr (non-nil): the check couldn't run, or ClickHouse failed with a
// non-user-facing code (e.g. unreachable, timeout, server-side). The caller
// warns rather than falsely marking the query invalid, since validity is
// unknown.
//
// Both nil means the statement is valid.
func (q *querier) explainBindCheck(ctx context.Context, stmt string, args []any) (invalidErr error, infraErr error) {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN PLAN "+stmt, args...)
if err != nil {
var ex *clickhouse.Exception
if errors.As(err, &ex) && userFacingClickHouseErrorCodes[chproto.Error(ex.Code)] {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", ex.Message), nil
}
return nil, err
}
rows.Close()
return nil, nil
}
// explainPlanNode is the subset of a ClickHouse `EXPLAIN json = 1, indexes = 1`
// plan node that granuleSkipScore needs: the node type, its per-index granule funnel,
// and its children.
type explainPlanNode struct {
NodeType string `json:"Node Type"`
Indexes []explainPlanIndex `json:"Indexes"`
Plans []explainPlanNode `json:"Plans"`
}
// explainPlanIndex is one index step under a ReadFromMergeTree node. The index
// steps run in sequence, so the first step's Initial Granules is the candidate
// total and the last step's Selected Granules is what survives all pruning.
type explainPlanIndex struct {
InitialGranules *int64 `json:"Initial Granules"`
SelectedGranules *int64 `json:"Selected Granules"`
}
// computeGranuleSkipScore runs `EXPLAIN json = 1, indexes = 1` against the telemetry
// store and returns a 0-100 score: the percentage of candidate granules
// eliminated by partition, primary-key, and skip-index pruning before any data
// is read (higher = more selective, reads less). Granules are summed across
// every ReadFromMergeTree node so multi-read queries (e.g. a resource-filter
// subquery plus the main read) are scored as a whole. Returns nil — not an
// error — when the plan exposes no MergeTree index analysis, so the caller
// simply omits the score.
func (q *querier) computeGranuleSkipScore(ctx context.Context, stmt string, args []any) (*float64, error) {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN json = 1, indexes = 1 "+stmt, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN for query score")
}
defer rows.Close()
// json=1 emits the plan as a single JSON document; read every row and join
// so we are robust to the driver splitting it across rows.
var sb strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN json row")
}
sb.WriteString(line)
sb.WriteByte('\n')
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN json row iteration failed")
}
var plans []struct {
Plan explainPlanNode `json:"Plan"`
}
if err := json.Unmarshal([]byte(sb.String()), &plans); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse EXPLAIN json")
}
var totalInitial, totalSelected int64
for i := range plans {
accumulateGranules(&plans[i].Plan, &totalInitial, &totalSelected)
}
if totalInitial <= 0 {
// No MergeTree index analysis in the plan — nothing to score.
return nil, nil
}
if totalSelected < 0 {
totalSelected = 0
}
skipped := float64(totalInitial-totalSelected) / float64(totalInitial)
if skipped < 0 {
skipped = 0
}
score := math.Round(skipped*100*100) / 100 // percentage, 2 decimal places
return &score, nil
}
// accumulateGranules walks the plan tree and, for every ReadFromMergeTree node,
// adds its candidate-granule total (first index step's Initial Granules) and
// surviving granules (last index step's Selected Granules) to the running sums.
func accumulateGranules(node *explainPlanNode, totalInitial, totalSelected *int64) {
if node.NodeType == "ReadFromMergeTree" && len(node.Indexes) > 0 {
var initial, selected *int64
for i := range node.Indexes {
if node.Indexes[i].InitialGranules != nil && initial == nil {
initial = node.Indexes[i].InitialGranules
}
if node.Indexes[i].SelectedGranules != nil {
selected = node.Indexes[i].SelectedGranules
}
}
if initial != nil && selected != nil {
*totalInitial += *initial
*totalSelected += *selected
}
}
for i := range node.Plans {
accumulateGranules(&node.Plans[i], totalInitial, totalSelected)
}
}

View File

@@ -220,6 +220,68 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
return newQuery.String(), nil
}
// Statement renders the PromQL query string after variable substitution. It
// is used by the dry-run/preview path; PromQL queries do not have a
// SQL-style argument list.
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
return &qbv5.Statement{Query: rendered}, nil
}
// PreviewStatements returns the underlying ClickHouse statement(s) this PromQL
// query would run, captured without executing them. PromQL is evaluated by the
// Prometheus engine rather than compiled to one SQL statement: the engine calls
// the storage adapter's Select per metric selector, which builds ClickHouse
// SQL. We drive the engine with a capturing Storage that records that SQL and
// returns empty results, so nothing is read from ClickHouse. Returns nil when
// the provider does not support capture (e.g. test doubles).
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
storer, ok := q.promEngine.(prometheus.StatementCapturer)
if !ok {
return nil, nil
}
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
start := int64(querybuilder.ToNanoSecs(q.tr.From))
end := int64(querybuilder.ToNanoSecs(q.tr.To))
capStorage, recorder := storer.CapturingStorage()
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
capStorage,
nil,
rendered,
time.Unix(0, start),
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
if e := tryEnhancePromQLExecError(err); e != nil {
return nil, e
}
return nil, enhancePromQLError(rendered, err)
}
defer qry.Close()
// Evaluate against the capturing storage: this drives a Select per selector
// (recording the SQL) but reads no data, so the result is discarded.
if res := qry.Exec(ctx); res.Err != nil {
if e := tryEnhancePromQLExecError(res.Err); e != nil {
return nil, e
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
return recorder.Statements(), nil
}
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{

View File

@@ -361,10 +361,6 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
continue
}
// Type is resolved now; validate aggregation compatibility against it.
if err := spec.Aggregations[i].ValidateForType(); err != nil {
return nil, "", err
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
if len(presentAggregations) == 0 {

View File

@@ -32,6 +32,12 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
return q.fromMS, q.toMS
}
// Statement renders the SQL statement for the trace operator query without
// executing it. It is used by the dry-run/preview path.
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
}
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
stmt, err := q.stmtBuilder.Build(
ctx,

View File

@@ -168,7 +168,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

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

View File

@@ -122,11 +122,7 @@ func NewModules(
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
}
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{

View File

@@ -211,8 +211,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
)
}

View File

@@ -1,71 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addUserDashboardPreference struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddUserDashboardPreferenceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_dashboard_preference"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserDashboardPreference{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addUserDashboardPreference) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addUserDashboardPreference) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "user_dashboard_preference",
Columns: []*sqlschema.Column{
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "is_pinned", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("user_id"),
ReferencedTableName: sqlschema.TableName("users"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addUserDashboardPreference) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -1,84 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type recreateUserDashboardPreference struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewRecreateUserDashboardPreferenceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("recreate_user_dashboard_pref"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &recreateUserDashboardPreference{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *recreateUserDashboardPreference) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up replaces the composite (user_id, dashboard_id) primary key with a surrogate
// id primary key, demotes the pair to a unique index, and adds created_at /
// updated_at. The table is dropped and recreated since it carries no data yet.
func (migration *recreateUserDashboardPreference) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().DropTable(&sqlschema.Table{Name: "user_dashboard_preference"})
sqls = append(sqls, migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "user_dashboard_preference",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "is_pinned", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("user_id"),
ReferencedTableName: sqlschema.TableName("users"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "user_dashboard_preference",
ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"},
})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *recreateUserDashboardPreference) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -13,13 +13,13 @@ import (
// Audit attributes — Action (What).
type AuditAttributes struct {
Action coretypes.Verb // guaranteed to be present
ActionCategory coretypes.ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
Action coretypes.Verb // guaranteed to be present
ActionCategory ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory, claims authtypes.Claims) AuditAttributes {
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category ActionCategory, claims authtypes.Claims) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
@@ -71,50 +71,23 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
// Audit attributes — Resource (On What).
// These are OTel resource attributes (placed on the Resource, not event attributes).
type ResourceAttributes struct {
Resource coretypes.Resource // guaranteed to be present
ResourceID string
// TargetResource names the counterpart of an attach/detach event (audit
// context only). nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
ResourceID string
ResourceKind coretypes.Kind // guaranteed to be present
}
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
func NewResourceAttributes(resourceID string, resourceKind coretypes.Kind) ResourceAttributes {
return ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
}
}
// NewAttachResourceAttributes builds resource attributes that additionally name
// the target counterpart (used for attach/detach audit events).
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) ResourceAttributes {
return ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
TargetResource: targetResource,
TargetResourceID: targetResourceID,
ResourceID: resourceID,
ResourceKind: resourceKind,
}
}
// PutResource writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) PutResource(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
func (attributes ResourceAttributes) PutResource(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.ResourceKind.String())
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
if attributes.ResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.object", attributes.Resource.Object(orgID, attributes.ResourceID))
}
if attributes.TargetResource != nil {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.kind", attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.target.id", attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.object", attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
}
}
// Audit attributes — Error (When outcome is failure)
@@ -220,24 +193,13 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
// Resource: " kind (id)" or " kind".
b.WriteString(" ")
b.WriteString(resourceAttributes.Resource.Kind().String())
b.WriteString(resourceAttributes.ResourceKind.String())
if resourceAttributes.ResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.ResourceID)
b.WriteString(")")
}
// Target (attach/detach context): " · target kind (id)" or " · target kind".
if resourceAttributes.TargetResource != nil {
b.WriteString(" to ")
b.WriteString(resourceAttributes.TargetResource.Kind().String())
if resourceAttributes.TargetResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.TargetResourceID)
b.WriteString(")")
}
}
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.
if auditAttributes.Outcome == OutcomeFailure {
errorType := errorAttributes.ErrorType

View File

@@ -36,7 +36,7 @@ func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, coretypes.ActionCategoryConfigurationChange, claims)
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, ActionCategoryConfigurationChange, claims)
assert.Equal(t, testCase.expectedOutcome, attrs.Outcome)
})
}
@@ -55,7 +55,7 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyResourceID",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDelete,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -63,8 +63,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) deleted dashboard",
@@ -73,7 +73,7 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyPrincipalEmail",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDelete,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -81,8 +81,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "019a1234-abcd-7000-8000-567800000001 deleted dashboard (abd)",
@@ -91,7 +91,7 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyPrincipalIDandEmail",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDelete,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -99,8 +99,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "deleted dashboard (abd)",
@@ -109,7 +109,7 @@ func TestNewBody(t *testing.T) {
name: "Success_AllPresent",
auditAttributes: AuditAttributes{
Action: coretypes.VerbCreate,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -117,8 +117,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("alice@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678)",
@@ -127,21 +127,21 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyEverythingOptional",
auditAttributes: AuditAttributes{
Action: coretypes.VerbUpdate,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: ResourceAttributes{
Resource: coretypes.ResourceMetaResourceRule,
ResourceKind: coretypes.MustNewKind("alert-rule"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "updated rule",
expectedBody: "updated alert-rule",
},
{
name: "Failure_AllPresent",
auditAttributes: AuditAttributes{
Action: coretypes.VerbUpdate,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
@@ -149,8 +149,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("viewer@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{
ErrorType: "forbidden",
@@ -169,7 +169,7 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
Resource: coretypes.ResourceUser,
ResourceKind: coretypes.MustNewKind("user"),
},
errorAttributes: ErrorAttributes{
ErrorType: "not-found",
@@ -187,8 +187,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",

View File

@@ -1,7 +1,11 @@
package coretypes
package audittypes
import "github.com/SigNoz/signoz/pkg/valuer"
// ActionCategory classifies the audit event per IEC 62443.
// See https://www.iec.ch/blog/understanding-iec-62443 for the standard reference.
type ActionCategory struct{ valuer.String }
var (
ActionCategoryAccessControl = ActionCategory{valuer.NewString("access_control")}
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString("configuration_change")}
@@ -9,10 +13,6 @@ var (
ActionCategorySystemEvent = ActionCategory{valuer.NewString("system_event")}
)
// ActionCategory classifies an audited action per IEC 62443.
// See https://www.iec.ch/blog/understanding-iec-62443 for the standard reference.
type ActionCategory struct{ valuer.String }
func (ActionCategory) Enum() []any {
return []any{
ActionCategoryAccessControl,

View File

@@ -44,8 +44,6 @@ type AuditEvent struct {
TransportAttributes TransportAttributes
}
// NewAuditEvent builds an audit event from pre-built resource attributes (which
// may carry attach/target context).
func NewAuditEventFromHTTPRequest(
req *http.Request,
route string,
@@ -53,14 +51,16 @@ func NewAuditEventFromHTTPRequest(
traceID oteltrace.TraceID,
spanID oteltrace.SpanID,
action coretypes.Verb,
actionCategory coretypes.ActionCategory,
actionCategory ActionCategory,
claims authtypes.Claims,
resourceAttributes ResourceAttributes,
resourceID string,
resourceKind coretypes.Kind,
errorType string,
errorCode string,
) AuditEvent {
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
principalAttributes := NewPrincipalAttributesFromClaims(claims)
resourceAttributes := NewResourceAttributes(resourceID, resourceKind)
errorAttributes := NewErrorAttributes(errorType, errorCode)
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
@@ -69,7 +69,7 @@ func NewAuditEventFromHTTPRequest(
TraceID: traceID,
SpanID: spanID,
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
EventName: NewEventName(resourceAttributes.Resource.Kind(), auditAttributes.Action),
EventName: NewEventName(resourceAttributes.ResourceKind, auditAttributes.Action),
AuditAttributes: auditAttributes,
PrincipalAttributes: principalAttributes,
ResourceAttributes: resourceAttributes,
@@ -89,7 +89,7 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
groups := make(map[resourceKey][]int)
order := make([]resourceKey, 0)
for i, event := range events {
key := resourceKey{kind: event.ResourceAttributes.Resource.Kind().String(), id: event.ResourceAttributes.ResourceID}
key := resourceKey{kind: event.ResourceAttributes.ResourceKind.String(), id: event.ResourceAttributes.ResourceID}
if _, exists := groups[key]; !exists {
order = append(order, key)
}
@@ -101,8 +101,7 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
resourceAttrs := resourceLogs.Resource().Attributes()
resourceAttrs.PutStr(string(semconv.ServiceNameKey), name)
resourceAttrs.PutStr(string(semconv.ServiceVersionKey), version)
head := events[groups[key][0]]
head.ResourceAttributes.PutResource(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
events[groups[key][0]].ResourceAttributes.PutResource(resourceAttrs)
scopeLogs := resourceLogs.ScopeLogs().AppendEmpty()
scopeLogs.Scope().SetName(scope)

View File

@@ -12,10 +12,10 @@ import (
)
var (
testDashboardResource = coretypes.ResourceMetaResourceDashboard
testDashboardKind = coretypes.MustNewKind("dashboard")
)
func TestNewAuditEvent(t *testing.T) {
func TestNewAuditEventFromHTTPRequest(t *testing.T) {
traceID := oteltrace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
spanID := oteltrace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}
@@ -26,10 +26,10 @@ func TestNewAuditEvent(t *testing.T) {
route string
statusCode int
action coretypes.Verb
category coretypes.ActionCategory
category ActionCategory
claims authtypes.Claims
resource coretypes.Resource
resourceID string
resourceKind coretypes.Kind
errorType string
errorCode string
expectedOutcome Outcome
@@ -42,10 +42,10 @@ func TestNewAuditEvent(t *testing.T) {
route: "/api/v1/dashboards",
statusCode: http.StatusOK,
action: coretypes.VerbCreate,
category: coretypes.ActionCategoryConfigurationChange,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
resourceKind: testDashboardKind,
expectedOutcome: OutcomeSuccess,
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678-efgh-9012)",
},
@@ -56,10 +56,10 @@ func TestNewAuditEvent(t *testing.T) {
route: "/api/v1/dashboards/{id}",
statusCode: http.StatusForbidden,
action: coretypes.VerbUpdate,
category: coretypes.ActionCategoryConfigurationChange,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "viewer@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
resourceKind: testDashboardKind,
errorType: "forbidden",
errorCode: "authz_forbidden",
expectedOutcome: OutcomeFailure,
@@ -80,14 +80,15 @@ func TestNewAuditEvent(t *testing.T) {
testCase.action,
testCase.category,
testCase.claims,
NewResourceAttributes(testCase.resource, testCase.resourceID),
testCase.resourceID,
testCase.resourceKind,
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
assert.Equal(t, testCase.expectedBody, event.Body)
assert.Equal(t, testCase.resource.Kind(), event.ResourceAttributes.Resource.Kind())
assert.Equal(t, testCase.resourceKind, event.ResourceAttributes.ResourceKind)
assert.Equal(t, testCase.resourceID, event.ResourceAttributes.ResourceID)
assert.Equal(t, testCase.action, event.AuditAttributes.Action)
assert.Equal(t, testCase.category, event.AuditAttributes.ActionCategory)
@@ -102,18 +103,18 @@ func TestNewAuditEvent(t *testing.T) {
}
}
func newTestEvent(resource coretypes.Resource, resourceID string, action coretypes.Verb) AuditEvent {
func newTestEvent(resourceKind coretypes.Kind, resourceID string, action coretypes.Verb) AuditEvent {
return AuditEvent{
Body: resource.Kind().String() + "." + action.PastTense(),
EventName: NewEventName(resource.Kind(), action),
Body: resourceKind.String() + "." + action.PastTense(),
EventName: NewEventName(resourceKind, action),
AuditAttributes: AuditAttributes{
Action: action,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
ResourceAttributes: ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
ResourceKind: resourceKind,
ResourceID: resourceID,
},
}
}
@@ -135,7 +136,7 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SingleEvent",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -145,9 +146,9 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameResource_MultipleEvents",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -157,8 +158,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "DifferentResources_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -168,8 +169,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameKind_DifferentIDs_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-002", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-002", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "dashboard"},
@@ -179,11 +180,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "InterleavedResources_GroupedCorrectly",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -202,6 +203,7 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
resourceLogs := logs.ResourceLogs().At(i)
resourceAttrs := resourceLogs.Resource().Attributes()
// Verify service resource attributes
serviceName, exists := resourceAttrs.Get("service.name")
assert.True(t, exists)
assert.Equal(t, "signoz", serviceName.Str())
@@ -210,6 +212,7 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, "0.90.0", serviceVersion.Str())
// Verify audit resource attributes on Resource (not event attributes)
kind, exists := resourceAttrs.Get("signoz.audit.resource.kind")
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceKinds[i], kind.Str())
@@ -218,11 +221,14 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceIDs[i], id.Str())
// Verify scope
assert.Equal(t, 1, resourceLogs.ScopeLogs().Len())
assert.Equal(t, "signoz.audit", resourceLogs.ScopeLogs().At(0).Scope().Name())
// Verify log record count per group
assert.Equal(t, testCase.expectedLogRecordCounts[i], resourceLogs.ScopeLogs().At(0).LogRecords().Len())
// Verify resource attrs are NOT in log record event attributes
for j := 0; j < resourceLogs.ScopeLogs().At(0).LogRecords().Len(); j++ {
recordAttrs := resourceLogs.ScopeLogs().At(0).LogRecords().At(j).Attributes()
_, hasKind := recordAttrs.Get("signoz.audit.resource.kind")

View File

@@ -1,99 +0,0 @@
package coretypes
import (
"net/http"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
const (
PhaseRequest ExtractPhase = iota
PhaseResponse
)
type ExtractPhase int
// ExtractorContext carries everything an extractor may read: Request + RequestBody
// are filled pre-handler, ResponseBody post-handler.
type ExtractorContext struct {
Request *http.Request
RequestBody []byte
ResponseBody []byte
}
type ResourceIDExtractor struct {
Phase ExtractPhase
Fn func(ExtractorContext) (string, error)
}
type ResourceIDsExtractor struct {
Phase ExtractPhase
Fn func(ExtractorContext) ([]string, error)
}
func (extractor ResourceIDExtractor) IsPhase(phase ExtractPhase) bool {
return extractor.Fn != nil && extractor.Phase == phase
}
func (extractor ResourceIDExtractor) RunFor(phase ExtractPhase, ec ExtractorContext) (string, bool) {
if !extractor.IsPhase(phase) {
return "", false
}
id, _ := extractor.Fn(ec)
return id, true
}
func (extractor ResourceIDsExtractor) IsPhase(phase ExtractPhase) bool {
return extractor.Fn != nil && extractor.Phase == phase
}
// OneID lifts a single-id extractor into a one-element ids extractor.
func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
return ResourceIDsExtractor{Phase: extractor.Phase, Fn: func(ec ExtractorContext) ([]string, error) {
id, err := extractor.Fn(ec)
if err != nil || id == "" {
return nil, err
}
return []string{id}, nil
}}
}
func PathParam(name string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
return mux.Vars(ec.Request)[name], nil
}}
}
func BodyJSONPath(path string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
return gjson.GetBytes(ec.RequestBody, path).String(), nil
}}
}
func BodyJSONArray(path string) ResourceIDsExtractor {
return ResourceIDsExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) ([]string, error) {
result := gjson.GetBytes(ec.RequestBody, path)
if !result.Exists() {
return nil, nil
}
array := result.Array()
ids := make([]string, 0, len(array))
for _, r := range array {
ids = append(ids, r.String())
}
return ids, nil
}}
}
func ResponseJSONPath(path string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseResponse, Fn: func(ec ExtractorContext) (string, error) {
return gjson.GetBytes(ec.ResponseBody, path).String(), nil
}}
}

View File

@@ -1,64 +0,0 @@
package coretypes
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
)
var errCodeResolvedResourcesNotFound = errors.MustNewCode("resolved_resources_not_found")
type resolvedKey struct{}
// ResolvedResource is the resolved form of a resource def, produced by the
// resource middleware and read by authz and audit.
type ResolvedResource interface {
Verb() Verb
Category() ActionCategory
SourceResource() Resource
SourceIDs() []string
SourceSelector() SelectorFunc
ResolveResponse(ec ExtractorContext)
// hasResponsePhase reports whether an id is resolved from the response body.
hasResponsePhase() bool
}
type ResolvedResourceWithTargetResource interface {
ResolvedResource
TargetResource() Resource
TargetIDs() []string
TargetSelector() SelectorFunc
// IsParentChild true: the target is a child audited along but not authz-checked
// (only the source is); false: a sibling peer that is also authz-checked.
IsParentChild() bool
}
func NewContextWithResolvedResources(ctx context.Context, resolved []ResolvedResource) context.Context {
return context.WithValue(ctx, resolvedKey{}, resolved)
}
func ResolvedResourcesFromContext(ctx context.Context) ([]ResolvedResource, error) {
resolved, ok := ctx.Value(resolvedKey{}).([]ResolvedResource)
if !ok {
return nil, errors.New(errors.TypeInternal, errCodeResolvedResourcesNotFound, "resolved resources not found in context")
}
return resolved, nil
}
// ShouldCaptureResponseBody reports whether any resolved resource in ctx derives
// an id from the response body.
func ShouldCaptureResponseBody(ctx context.Context) bool {
resolved, err := ResolvedResourcesFromContext(ctx)
if err != nil {
return false
}
for _, resource := range resolved {
if resource.hasResponsePhase() {
return true
}
}
return false
}

View File

@@ -1,69 +0,0 @@
package coretypes
type resolvedResource struct {
verb Verb
category ActionCategory
resource Resource
selector SelectorFunc
idExtractor ResourceIDExtractor
ids []string
}
func NewResolvedResource(
verb Verb,
category ActionCategory,
resource Resource,
idExtractor ResourceIDExtractor,
selector SelectorFunc,
ec ExtractorContext,
) ResolvedResource {
resolved := &resolvedResource{
verb: verb,
category: category,
resource: resource,
selector: selector,
idExtractor: idExtractor,
}
resolved.fill(PhaseRequest, ec)
return resolved
}
func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext) {
if id, ok := resolved.idExtractor.RunFor(phase, ec); ok && id != "" {
resolved.ids = []string{id}
}
}
func (resolved *resolvedResource) Verb() Verb {
return resolved.verb
}
func (resolved *resolvedResource) Category() ActionCategory {
return resolved.category
}
func (resolved *resolvedResource) SourceResource() Resource {
return resolved.resource
}
// An empty id (when none resolved) means collection-level access.
func (resolved *resolvedResource) SourceIDs() []string {
if len(resolved.ids) == 0 {
return []string{""}
}
return resolved.ids
}
func (resolved *resolvedResource) SourceSelector() SelectorFunc {
return resolved.selector
}
func (resolved *resolvedResource) ResolveResponse(ec ExtractorContext) {
resolved.fill(PhaseResponse, ec)
}
func (resolved *resolvedResource) hasResponsePhase() bool {
return resolved.idExtractor.IsPhase(PhaseResponse)
}

View File

@@ -1,108 +0,0 @@
package coretypes
type resolvedResourceWithTarget struct {
verb Verb
category ActionCategory
sourceResource Resource
sourceSelector SelectorFunc
sourceExtractor ResourceIDsExtractor
sourceIDs []string
targetResource Resource
targetSelector SelectorFunc
targetExtractor ResourceIDsExtractor
targetIDs []string
parentChild bool
}
func NewResolvedResourceWithTarget(
verb Verb,
category ActionCategory,
sourceResource Resource,
sourceExtractor ResourceIDsExtractor,
sourceSelector SelectorFunc,
targetResource Resource,
targetExtractor ResourceIDsExtractor,
targetSelector SelectorFunc,
parentChild bool,
ec ExtractorContext,
) ResolvedResourceWithTargetResource {
resolved := &resolvedResourceWithTarget{
verb: verb,
category: category,
sourceResource: sourceResource,
sourceSelector: sourceSelector,
sourceExtractor: sourceExtractor,
targetResource: targetResource,
targetSelector: targetSelector,
targetExtractor: targetExtractor,
parentChild: parentChild,
}
resolved.fill(PhaseRequest, ec)
return resolved
}
func (resolved *resolvedResourceWithTarget) fill(phase ExtractPhase, ec ExtractorContext) {
if resolved.sourceExtractor.IsPhase(phase) {
if ids, _ := resolved.sourceExtractor.Fn(ec); len(ids) > 0 {
resolved.sourceIDs = ids
}
}
if resolved.targetExtractor.IsPhase(phase) {
if ids, _ := resolved.targetExtractor.Fn(ec); len(ids) > 0 {
resolved.targetIDs = ids
}
}
}
func (resolved *resolvedResourceWithTarget) Verb() Verb {
return resolved.verb
}
func (resolved *resolvedResourceWithTarget) Category() ActionCategory {
return resolved.category
}
func (resolved *resolvedResourceWithTarget) SourceResource() Resource {
return resolved.sourceResource
}
// An empty id (when none resolved) means collection-level access.
func (resolved *resolvedResourceWithTarget) SourceIDs() []string {
if len(resolved.sourceIDs) == 0 {
return []string{""}
}
return resolved.sourceIDs
}
func (resolved *resolvedResourceWithTarget) SourceSelector() SelectorFunc {
return resolved.sourceSelector
}
func (resolved *resolvedResourceWithTarget) TargetResource() Resource {
return resolved.targetResource
}
func (resolved *resolvedResourceWithTarget) TargetIDs() []string {
if len(resolved.targetIDs) == 0 {
return []string{""}
}
return resolved.targetIDs
}
func (resolved *resolvedResourceWithTarget) TargetSelector() SelectorFunc {
return resolved.targetSelector
}
func (resolved *resolvedResourceWithTarget) IsParentChild() bool {
return resolved.parentChild
}
func (resolved *resolvedResourceWithTarget) ResolveResponse(ec ExtractorContext) {
resolved.fill(PhaseResponse, ec)
}
func (resolved *resolvedResourceWithTarget) hasResponsePhase() bool {
return resolved.sourceExtractor.IsPhase(PhaseResponse) || resolved.targetExtractor.IsPhase(PhaseResponse)
}

View File

@@ -1,48 +1,15 @@
package coretypes
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
import "encoding/json"
const (
WildCardSelectorString string = "*"
)
var errCodeInvalidResourceID = errors.MustNewCode("invalid_resource_id")
var WildcardSelector SelectorFunc = func(_ context.Context, resource Resource, _ string, _ valuer.UUID) ([]Selector, error) {
return []Selector{resource.Type().MustSelector(WildCardSelectorString)}, nil
}
var IDSelector SelectorFunc = func(_ context.Context, resource Resource, id string, _ valuer.UUID) ([]Selector, error) {
if id == "" {
return nil, errors.Newf(
errors.TypeInvalidInput,
errCodeInvalidResourceID,
"resource id is required for %s",
resource.Kind().String(),
)
}
selector, err := resource.Type().Selector(id)
if err != nil {
return nil, err
}
return []Selector{selector, resource.Type().MustSelector(WildCardSelectorString)}, nil
}
type Selector struct {
val string
}
// SelectorFunc maps a resolved id (+ its resource) to authz FGA selectors.
type SelectorFunc func(ctx context.Context, resource Resource, id string, orgID valuer.UUID) ([]Selector, error)
func (selector *Selector) MarshalJSON() ([]byte, error) {
return json.Marshal(selector.val)
}

View File

@@ -1,59 +0,0 @@
package dashboardtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
var ErrCodeDashboardListFilterInvalid = errors.MustNewCode("dashboard_list_filter_invalid")
// ReservedOps lists the operators each reserved (column-level) DSL key accepts.
// Any non-reserved key is treated as a tag key and uses TagKeyOps.
var ReservedOps = map[DSLKey]map[qbtypesv5.FilterOperator]struct{}{
DSLKeyName: stringSearchOps(),
DSLKeyDescription: stringSearchOps(),
DSLKeyCreatedAt: numericRangeOps(),
DSLKeyUpdatedAt: numericRangeOps(),
DSLKeyCreatedBy: stringSearchOps(),
DSLKeyLocked: opsSet(qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual),
}
// TagKeyOps applies to every non-reserved DSL key — the operator targets the
// tag's value with an implicit case-insensitive match on the tag's key.
var TagKeyOps = opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
qbtypesv5.FilterOperatorExists, qbtypesv5.FilterOperatorNotExists,
)
func stringSearchOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
)
}
func numericRangeOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq,
qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween,
)
}
func opsSet(ops ...qbtypesv5.FilterOperator) map[qbtypesv5.FilterOperator]struct{} {
m := make(map[qbtypesv5.FilterOperator]struct{}, len(ops))
for _, op := range ops {
m[op] = struct{}{}
}
return m
}

View File

@@ -1,191 +0,0 @@
package dashboardtypes
import (
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
DefaultListLimit = 20
MaxListLimit = 200
)
// ListSort is the sort field for the dashboard list endpoint. The value is a
// stable enum so callers can't ask for arbitrary columns.
type ListSort struct{ valuer.String }
var (
ListSortUpdatedAt = ListSort{valuer.NewString("updated_at")}
ListSortCreatedAt = ListSort{valuer.NewString("created_at")}
ListSortName = ListSort{valuer.NewString("name")}
)
func (ListSort) Enum() []any {
return []any{ListSortUpdatedAt, ListSortCreatedAt, ListSortName}
}
func (s ListSort) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
type ListOrder struct{ valuer.String }
var (
ListOrderAsc = ListOrder{valuer.NewString("asc")}
ListOrderDesc = ListOrder{valuer.NewString("desc")}
)
func (ListOrder) Enum() []any {
return []any{ListOrderAsc, ListOrderDesc}
}
func (o ListOrder) IsValid() bool {
return slices.ContainsFunc(o.Enum(), func(v any) bool { return v == o })
}
var ErrCodeDashboardListInvalid = errors.MustNewCode("dashboard_list_invalid")
type ListDashboardsV2Params struct {
Query string `query:"query"`
Sort ListSort `query:"sort"`
Order ListOrder `query:"order"`
Limit int `query:"limit"`
Offset int `query:"offset"`
}
// Validate fills in defaults (sort=updated_at, order=desc, limit=20) and
// rejects out-of-allowlist sort/order values and bad limit/offset. Limit is
// clamped to MaxListLimit on the high side. Sort/order are case-insensitive —
// valuer.String lowercases them at bind time.
func (p *ListDashboardsV2Params) Validate() error {
if p.Sort.IsZero() {
p.Sort = ListSortUpdatedAt
} else if !p.Sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid sort %q — expected one of: `updated_at`, `created_at`, `name`", p.Sort)
}
if p.Order.IsZero() {
p.Order = ListOrderDesc
} else if !p.Order.IsValid() {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid order %q — expected `asc` or `desc`", p.Order)
}
if p.Limit == 0 {
p.Limit = DefaultListLimit
} else if p.Limit < 0 {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid limit %d — must be a positive integer", p.Limit)
} else if p.Limit > MaxListLimit {
p.Limit = MaxListLimit
}
if p.Offset < 0 {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid offset %d — must be a non-negative integer", p.Offset)
}
return nil
}
type listedDashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId" required:"true"`
Locked bool `json:"locked" required:"true"`
Source Source `json:"source" required:"true"`
SchemaVersion string `json:"schemaVersion" required:"true"`
Name string `json:"name" required:"true"`
Image string `json:"image,omitempty"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
Spec listedDashboardV2Spec `json:"spec" required:"true"`
}
type listedDashboardV2Spec struct {
Display Display `json:"display,omitempty"`
}
func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
return &listedDashboardV2{
Identifiable: v2.Identifiable,
TimeAuditable: v2.TimeAuditable,
UserAuditable: v2.UserAuditable,
OrgID: v2.OrgID,
Locked: v2.Locked,
Source: v2.Source,
SchemaVersion: v2.SchemaVersion,
Name: v2.Name,
Image: v2.Image,
Tags: tagtypes.NewGettableTagsFromTags(v2.Tags),
Spec: listedDashboardV2Spec{Display: v2.Spec.Display},
}
}
type ListableDashboardV2 struct {
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
}
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
items := make([]*listedDashboardV2, len(dashboards))
for i, d := range dashboards {
v2, err := d.ToDashboardV2(tagsByEntity[d.ID])
if err != nil {
return nil, err
}
items[i] = newListedDashboardV2(v2)
}
return &ListableDashboardV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
}, nil
}
// listedDashboardForUserV2 is a listed dashboard plus the calling user's pin
// state. Only the per-user list endpoint emits this; the pure list omits pins.
type listedDashboardForUserV2 struct {
listedDashboardV2
Pinned bool `json:"pinned" required:"true"`
}
type ListableDashboardForUserV2 struct {
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
}
// StorableDashboardWithPinInfo is the per-row shape Store.ListForUser returns: the dashboard
// joined with the calling user's pin state, so the module layer can attach tags
// and assemble the gettable view.
type StorableDashboardWithPinInfo struct {
Dashboard *StorableDashboard
Pinned bool
}
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardForUserV2, error) {
items := make([]*listedDashboardForUserV2, len(rows))
for i, r := range rows {
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
if err != nil {
return nil, err
}
items[i] = &listedDashboardForUserV2{
listedDashboardV2: *newListedDashboardV2(v2),
Pinned: r.Pinned,
}
}
return &ListableDashboardForUserV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
}, nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"k8s.io/apimachinery/pkg/util/validation"
)
@@ -30,7 +31,7 @@ const (
DSLKeyUpdatedAt DSLKey = "updated_at"
DSLKeyCreatedBy DSLKey = "created_by"
DSLKeyLocked DSLKey = "locked"
DSLKeySource DSLKey = "source"
DSLKeyPublic DSLKey = "public"
)
// reservedDSLKeys are dashboard column-level filter names in the list-query DSL.
@@ -43,7 +44,7 @@ var reservedDSLKeys = map[DSLKey]struct{}{
DSLKeyUpdatedAt: {},
DSLKeyCreatedBy: {},
DSLKeyLocked: {},
DSLKeySource: {},
DSLKeyPublic: {},
}
type DashboardV2 struct {
@@ -61,17 +62,10 @@ type DashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) ErrIfNotMutable() error {
func (d *DashboardV2) CanUpdate() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
return nil
}
func (d *DashboardV2) ErrIfNotUpdatable() error {
if err := d.ErrIfNotMutable(); err != nil {
return err
}
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
@@ -79,7 +73,7 @@ func (d *DashboardV2) ErrIfNotUpdatable() error {
}
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.ErrIfNotUpdatable(); err != nil {
if err := d.CanUpdate(); err != nil {
return err
}
if updatable.Name != d.Name {
@@ -93,7 +87,7 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
return nil
}
func (d *DashboardV2) ErrIfNotLockable(isAdmin bool, updatedBy string) error {
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
}
@@ -107,7 +101,7 @@ func (d *DashboardV2) ErrIfNotLockable(isAdmin bool, updatedBy string) error {
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.ErrIfNotLockable(isAdmin, updatedBy); err != nil {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
@@ -116,16 +110,6 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) ErrIfNotDeletable() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
if !d.Source.isUserDeletable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", d.Source)
}
return nil
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image,omitempty"`
@@ -174,6 +158,9 @@ func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboardV2(tmp)
if p.Spec.Display == nil {
p.Spec.Display = &common.Display{}
}
if !p.GenerateName && p.Spec.Display.Name == "" {
p.Spec.Display.Name = p.Name
}
@@ -200,7 +187,7 @@ func (p *PostableDashboardV2) validateName() error {
if p.Name != "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name must be empty when generateName is true, got %q", p.Name)
}
if p.Spec.Display.Name == "" {
if p.Spec.Display == nil || p.Spec.Display.Name == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.display.name is required when generateName is true")
}
return nil
@@ -344,6 +331,9 @@ func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*u = UpdatableDashboardV2(tmp)
if u.Spec.Display == nil {
u.Spec.Display = &common.Display{}
}
if u.Spec.Display.Name == "" {
u.Spec.Display.Name = u.Name
}

View File

@@ -8,9 +8,10 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -165,7 +166,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
DashboardV2MetadataBase: DashboardV2MetadataBase{SchemaVersion: SchemaVersion},
GenerateName: true,
Spec: DashboardSpec{
Display: Display{Name: "My Dashboard!"},
Display: &common.Display{Name: "My Dashboard!"},
},
}

View File

@@ -17,12 +17,12 @@ import (
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardSpec struct {
Display Display `json:"display" required:"true"`
Display *common.Display `json:"display,omitempty"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables" required:"true" nullable:"false"`
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration,omitempty"`
Variables []Variable `json:"variables,omitempty"`
Panels map[string]*Panel `json:"panels"`
Layouts []Layout `json:"layouts"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
}

View File

@@ -18,23 +18,14 @@ import (
// ══════════════════════════════════════════════
type PanelPlugin struct {
Kind PanelPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
Kind PanelPluginKind `json:"kind"`
Spec any `json:"spec"`
}
// PrepareJSONSchema marks the envelope with x-signoz-discriminator;
// signoz.attachDiscriminators promotes it to a real OpenAPI 3 discriminator
// (and strips the duplicate parent properties) after reflection.
// PrepareJSONSchema drops the reflected struct shape (type: object, properties)
// from the envelope so that only the JSONSchemaOneOf result binds.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
})
return clearOneOfParentShape(s)
}
func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
@@ -81,19 +72,12 @@ func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type QueryPlugin struct {
Kind QueryPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
Kind QueryPluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(QueryKindBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec"),
string(QueryKindComposite): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery"),
string(QueryKindFormula): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula"),
string(QueryKindPromQL): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery"),
string(QueryKindClickHouseSQL): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery"),
string(QueryKindTraceOperator): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator"),
})
return clearOneOfParentShape(s)
}
func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
@@ -139,16 +123,12 @@ func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type VariablePlugin struct {
Kind VariablePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
Kind VariablePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(VariableKindDynamic): schemaRef("DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec"),
string(VariableKindQuery): schemaRef("DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec"),
string(VariableKindCustom): schemaRef("DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec"),
})
return clearOneOfParentShape(s)
}
func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
@@ -191,14 +171,12 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
// ══════════════════════════════════════════════
type DatasourcePlugin struct {
Kind DatasourcePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
Kind DatasourcePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(DatasourceKindSigNoz): schemaRef("DashboardtypesDatasourcePluginVariantStruct"),
})
return clearOneOfParentShape(s)
}
func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
@@ -313,28 +291,10 @@ func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
return target, nil
}
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators
// promotes into a native OpenAPI 3 discriminator after reflection.
const signozDiscriminatorKey = "x-signoz-discriminator"
// schemaRef builds a local component schema reference for a discriminator mapping.
func schemaRef(name string) string {
return "#/components/schemas/" + name
}
// markDiscriminator tags a oneOf envelope schema with x-signoz-discriminator so
// signoz.attachDiscriminators promotes it to a real OpenAPI 3 discriminator,
// keyed on propertyName, with the given value -> schema-ref mapping. This turns
// the union into a discriminated DTO (instead of an intersection) for generated
// clients.
func markDiscriminator(s *jsonschema.Schema, propertyName string, mapping map[string]string) error {
if s.ExtraProperties == nil {
s.ExtraProperties = map[string]any{}
}
s.ExtraProperties[signozDiscriminatorKey] = map[string]any{
"propertyName": propertyName,
"mapping": mapping,
}
// clearOneOfParentShape drops Type and Properties on a schema that also has a JSONSchemaOneOf.
func clearOneOfParentShape(s *jsonschema.Schema) error {
s.Type = nil
s.Properties = nil
return nil
}

View File

@@ -13,11 +13,6 @@ import (
"github.com/swaggest/jsonschema-go"
)
type Display struct {
Name string `json:"name" required:"true"`
Description string `json:"description,omitempty"`
}
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
@@ -33,8 +28,8 @@ type DatasourceSpec struct {
// ══════════════════════════════════════════════
type Panel struct {
Kind PanelKind `json:"kind" required:"true"`
Spec PanelSpec `json:"spec" required:"true"`
Kind PanelKind `json:"kind"`
Spec PanelSpec `json:"spec"`
}
// PanelKind is the panel envelope discriminator. Perses leaves it a free
@@ -59,10 +54,10 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
}
type PanelSpec struct {
Display Display `json:"display" required:"true"`
Plugin PanelPlugin `json:"plugin" required:"true"`
Queries []Query `json:"queries" required:"true"`
Links []dashboard.Link `json:"links,omitempty"`
Display *dashboard.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
@@ -70,13 +65,13 @@ type PanelSpec struct {
// ══════════════════════════════════════════════
type Query struct {
Kind qb.RequestType `json:"kind" required:"true"`
Spec QuerySpec `json:"spec" required:"true"`
Kind qb.RequestType `json:"kind"`
Spec QuerySpec `json:"spec"`
}
type QuerySpec struct {
Name string `json:"name,omitempty"`
Plugin QueryPlugin `json:"plugin" required:"true"`
Plugin QueryPlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
@@ -87,15 +82,12 @@ type QuerySpec struct {
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
Kind variable.Kind `json:"kind"`
Spec any `json:"spec"`
}
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(variable.KindList): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec"),
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec"),
})
return clearOneOfParentShape(s)
}
func (v *Variable) UnmarshalJSON(data []byte) error {
@@ -143,7 +135,7 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display Display `json:"display" required:"true"`
Display *variable.Display `json:"display,omitempty"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
@@ -163,8 +155,8 @@ type ListVariableSpec struct {
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
// leaf imports.
type Layout struct {
Kind dashboard.LayoutKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
Kind dashboard.LayoutKind `json:"kind"`
Spec any `json:"spec"`
}
// layoutSpecs is the layout sum type factory. Perses only defines
@@ -175,9 +167,7 @@ var layoutSpecs = map[dashboard.LayoutKind]func() any{
}
func (Layout) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(dashboard.KindGridLayout): schemaRef("DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec"),
})
return clearOneOfParentShape(s)
}
func (l *Layout) UnmarshalJSON(data []byte) error {

View File

@@ -93,21 +93,14 @@ func (b BuilderQuerySpec) MarshalJSON() ([]byte, error) {
return json.Marshal(b.Spec)
}
// PrepareJSONSchema marks the envelope with x-signoz-discriminator keyed on
// `signal`. Each QueryBuilderQuery[T] variant pins `signal` to its one value
// (via its own PrepareJSONSchema in the qb package), so the union resolves
// cleanly even though it doesn't carry a `kind`.
// PrepareJSONSchema drops the reflected struct shape so only the
// JSONSchemaOneOf result binds.
func (BuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "signal", map[string]string{
telemetrytypes.SignalLogs.StringValue(): schemaRef("Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation"),
telemetrytypes.SignalMetrics.StringValue(): schemaRef("Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation"),
telemetrytypes.SignalTraces.StringValue(): schemaRef("Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation"),
})
return clearOneOfParentShape(s)
}
// JSONSchemaOneOf exposes the three signal-dispatched shapes a builder query
// can take. Mirrors qb.UnmarshalBuilderQueryBySignal's runtime dispatch. Each
// QueryBuilderQuery[T] pins its own `signal` enum (see its PrepareJSONSchema).
// can take. Mirrors qb.UnmarshalBuilderQueryBySignal's runtime dispatch.
func (BuilderQuerySpec) JSONSchemaOneOf() []any {
return []any{
qb.QueryBuilderQuery[qb.LogAggregation]{},

View File

@@ -51,10 +51,6 @@ func (s *Source) UnmarshalJSON(data []byte) error {
return s.s.UnmarshalJSON(data)
}
func (s Source) isUserDeletable() bool {
return s == SourceUser
}
func NewSource(source string) (Source, error) {
candidate := Source{s: valuer.NewString(source)}
if !candidate.IsValid() {

View File

@@ -32,23 +32,4 @@ type Store interface {
DeletePublic(context.Context, string) error
RunInTx(context.Context, func(context.Context) error) error
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
// int64 return is the total row count for the filter (pre-limit/offset).
// ListV2 is the pure list; ListForUser additionally joins the caller's pins.
ListV2(ctx context.Context, orgID valuer.UUID, params *ListDashboardsV2Params) ([]*StorableDashboard, int64, error)
ListForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *ListDashboardsV2Params) ([]*StorableDashboardWithPinInfo, int64, error)
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
PinForUser(ctx context.Context, preference *UserDashboardPreference) error
UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error
DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}

View File

@@ -1,36 +0,0 @@
package dashboardtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
const MaxPinnedDashboardsPerUser = 10
var ErrCodePinnedDashboardLimitHit = errors.MustNewCode("pinned_dashboard_limit_hit")
// Only the pin is tracked for now; more preferences can be added later.
type UserDashboardPreference struct {
bun.BaseModel `bun:"table:user_dashboard_preference,alias:user_dashboard_preference"`
types.Identifiable
types.TimeAuditable
UserID valuer.UUID `bun:"user_id,type:text"`
DashboardID valuer.UUID `bun:"dashboard_id,type:text"`
IsPinned bool `bun:"is_pinned,notnull,default:false"`
}
func NewUserDashboardPreference(userID, dashboardID valuer.UUID) *UserDashboardPreference {
now := time.Now()
return &UserDashboardPreference{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserID: userID,
DashboardID: dashboardID,
IsPinned: true,
}
}

View File

@@ -137,7 +137,7 @@ func (t *Type) Scan(src interface{}) error {
}
func (t Type) IsPercentileSpaceAggregationAllowed() bool {
return t == HistogramType || t == ExpHistogramType
return t == HistogramType || t == ExpHistogramType || t == SummaryType
}
var (

View File

@@ -2,11 +2,9 @@ package querybuildertypesv5
import (
"fmt"
"slices"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/swaggest/jsonschema-go"
)
type QueryBuilderQuery[T any] struct {
@@ -71,32 +69,6 @@ type QueryBuilderQuery[T any] struct {
ShiftBy int64 `json:"-"`
}
// PrepareJSONSchema pins `signal` to the single value implied by the aggregation
// type T, as an inline single-value enum, and marks it required. This lets a
// oneOf over the QueryBuilderQuery[T] instantiations be discriminated by signal.
func (QueryBuilderQuery[T]) PrepareJSONSchema(s *jsonschema.Schema) error {
var signal telemetrytypes.Signal
switch any(*new(T)).(type) {
case LogAggregation:
signal = telemetrytypes.SignalLogs
case MetricAggregation:
signal = telemetrytypes.SignalMetrics
case TraceAggregation:
signal = telemetrytypes.SignalTraces
default:
return nil
}
if _, ok := s.Properties["signal"]; !ok {
return nil
}
prop := (&jsonschema.Schema{}).WithType(jsonschema.String.Type()).WithEnum(signal.StringValue())
s.Properties["signal"] = prop.ToSchemaOrBool()
if !slices.Contains(s.Required, "signal") {
s.Required = append(s.Required, "signal")
}
return nil
}
// Copy creates a deep copy of the QueryBuilderQuery.
func (q QueryBuilderQuery[T]) Copy() QueryBuilderQuery[T] {
// start with a shallow copy

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
@@ -64,6 +65,137 @@ type QueryRangeResponse struct {
QBEvent *QBEvent `json:"-"`
}
// QueryRangePreviewResponse describes the dry-run output of a query range
// request. Each entry corresponds to a single query in the composite query.
type QueryRangePreviewResponse struct {
Queries map[string]QueryPreview `json:"queries"`
}
// ExplainVariant identifies one of the ClickHouse EXPLAIN modes that the
// preview endpoint can run against a rendered SQL statement.
type ExplainVariant string
const (
ExplainVariantNone ExplainVariant = ""
// ExplainVariantPlan returns the query execution plan tree (what gets read
// and how it's aggregated).
ExplainVariantPlan ExplainVariant = "plan"
// ExplainVariantEstimate returns ClickHouse's per-table estimate of the
// parts/rows/marks the query will read — an absolute cost estimate that
// complements the (ratio-based) granuleSkipScore.
ExplainVariantEstimate ExplainVariant = "estimate"
)
// QueryRangePreviewOptions carries per-call options for the query range
// preview (dry-run) endpoint. The zero value produces a lightweight,
// verdict-only preview (valid/error/warnings per query, no rendered SQL).
type QueryRangePreviewOptions struct {
// Explain selects which ClickHouse EXPLAIN variant to run for each rendered
// SQL statement. Leave empty to skip EXPLAIN. Implies Verbose (the EXPLAIN
// output attaches to each statement).
Explain ExplainVariant
// Verbose includes the rendered ClickHouse statement(s) (Statements) in the
// response. The default (false) returns only the per-query verdict
// (valid/error/warnings) plus the headline GranuleSkipScore — every query is
// still fully validated, just not rendered into the response. Requesting
// Explain implies Verbose, since EXPLAIN output attaches to each statement.
Verbose bool
// IncludeGranuleSkipScore computes the GranuleSkipScore. The HTTP endpoint
// sets it true by default (the headline top-level score is returned even in
// the lightweight, non-verbose response), and only false on ?score=false.
// When the response includes statements (Verbose/Explain), each statement
// also carries its own GranuleSkipScore and the top-level one is their
// minimum. Computing it costs one ClickHouse EXPLAIN per statement.
IncludeGranuleSkipScore bool
}
// QueryRangePreviewParams documents the query-string parameters accepted by the
// query range preview (dry-run) endpoint.
type QueryRangePreviewParams struct {
// Explain selects which ClickHouse EXPLAIN variant to run against each
// rendered SQL statement. Empty or "false" skips EXPLAIN; "true" maps to
// "plan". Allowed: plan (execution plan tree), estimate (parts/rows/marks
// to read). Implies verbose.
Explain string `query:"explain"`
// Verbose, when "true", includes the rendered ClickHouse statement(s) in the
// response. The default response is lightweight: the per-query verdict
// (valid/error/warnings) plus the top-level granuleSkipScore. Requesting
// explain implies verbose.
Verbose string `query:"verbose"`
// Score controls the granuleSkipScore (granule-skip selectivity, 0-100;
// higher is better). It defaults to "true" — the top-level score is returned
// even in the lightweight response. Set score=false to skip it (and its
// ClickHouse EXPLAIN round trips) for the cheapest validation-only preview.
Score string `query:"score"`
}
// PrepareJSONSchema adds description to the QueryRangePreviewResponse schema.
func (q *QueryRangePreviewResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with optional EXPLAIN output and granule-skip score when requested.")
return nil
}
// QueryPreview is the dry-run result for a single query, keyed by query name
// in QueryRangePreviewResponse.Queries.
type QueryPreview struct {
// Valid is the headline verdict for this query: true when it previewed
// without error, false when Error is set. It is always present (derived from
// Error at marshal time) so an agent can branch on a single boolean instead
// of testing for the presence of the error object.
Valid bool `json:"valid"`
// Error describes why this query is invalid or could not be previewed; nil
// when the query previewed successfully. It is the structured form
// (code, message, and — when available — suggestions and invalidReferences)
// so an agent can act on it programmatically instead of parsing a string.
Error error `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
// Score is the headline selectivity for this query: the percentage (0-100) of
// candidate granules eliminated by partition, primary-key, and skip-index
// pruning before any data is read (higher = less data read). It is the
// minimum of the per-statement Statements[].GranuleSkipScore values — the
// least-selective (worst) underlying statement, which dominates cost.
// Returned by default; omitted when ?score=false or no statement reads a
// MergeTree table.
Score *float64 `json:"score,omitempty"`
// Statements are the underlying ClickHouse statement(s) this query renders to,
// in execution order. Builder, ClickHouse SQL, and trace-operator queries
// render exactly one; a PromQL query renders one per metric selector (the
// Prometheus engine issues a statement per selector). Empty for a
// validation-only preview, a query that failed to render (see Error), or one
// that resolves to no data (a fully-missing metric, see Warnings).
Statements []PreviewStatement `json:"statements,omitempty"`
}
// PreviewStatement is one rendered ClickHouse statement the query will execute,
// with its bound args and — when requested — its EXPLAIN output and
// GranuleSkipScore.
type PreviewStatement struct {
Query string `json:"query"`
Args []any `json:"args,omitempty"`
Explain string `json:"explain,omitempty"`
GranuleSkipScore *float64 `json:"granuleSkipScore,omitempty"`
}
// MarshalJSON renders Error as the structured error form (code, message and,
// when present, suggestions/invalidReferences) instead of the default {} that a
// bare error interface produces, so an agent consuming the dry-run can act on it
// programmatically.
func (p QueryPreview) MarshalJSON() ([]byte, error) {
type alias QueryPreview
out := struct {
alias
Error *errors.JSON `json:"error,omitempty"`
}{alias: alias(p)}
out.alias.Error = nil
// Derive the verdict from the error so callers can't desync the two.
out.alias.Valid = p.Error == nil
if p.Error != nil {
out.Error = errors.AsJSON(p.Error)
}
return json.Marshal(out)
}
var _ jsonschema.Preparer = &QueryRangeResponse{}
// PrepareJSONSchema adds description to the QueryRangeResponse schema.

View File

@@ -317,19 +317,6 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
return nil
}
func (m MetricAggregation) ValidateForType() error {
if m.SpaceAggregation.IsPercentile() && !m.Type.IsPercentileSpaceAggregationAllowed() {
return errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"invalid space aggregation `%s` for metric type `%s`, percentile space aggregations are only supported for `histogram`, `exponentialhistogram` metric types",
m.SpaceAggregation.StringValue(),
m.Type.StringValue(),
)
}
return nil
}
func (q *QueryBuilderQuery[T]) validateLimitAndPagination(cfg validationConfig) error {
if cfg.skipLimitOffsetValidation {
return nil
@@ -548,6 +535,68 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
return nil
}
// ValidateRequestScope validates request-level invariants — time range,
// request type, the raw/trace metric-query restriction, non-empty composite
// query, unique builder query names, and not-all-disabled — WITHOUT validating
// individual query specs, and returns the ValidationOptions for the request
// type. The dry-run/preview path uses this so that per-query spec errors can be
// attributed to each query (via QueryEnvelope.Validate) instead of aborting the
// whole request on the first one, the way Validate does. The normal execution
// path keeps using the fail-fast Validate.
func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
}
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
opts = GetValidationOptions(r.RequestType)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar")
}
if r.RequestType == RequestTypeRaw || r.RequestType == RequestTypeRawStream || r.RequestType == RequestTypeTrace {
for _, envelope := range r.CompositeQuery.Queries {
if envelope.GetSignal() == telemetrytypes.SignalMetrics {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "raw request type is not supported for metric queries")
}
}
}
if len(r.CompositeQuery.Queries) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "at least one query is required")
}
// Builder query names must be unique across the composite query.
queryNames := make(map[string]bool)
for _, envelope := range r.CompositeQuery.Queries {
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
name := envelope.GetQueryName()
if name != "" {
if queryNames[name] {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "duplicate query name '%s'", name)
}
queryNames[name] = true
}
}
}
if err := r.validateAllQueriesNotDisabled(); err != nil {
return nil, err
}
return opts, nil
}
// Validate validates a single query envelope's spec. It is the per-query
// counterpart to QueryRangeRequest.ValidateRequestScope, used by the dry-run to
// report each query's structural error independently.
func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
return validateQueryEnvelope(e, opts...)
}
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled.
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
for _, envelope := range r.CompositeQuery.Queries {

View File

@@ -1421,62 +1421,3 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
}
})
}
func TestMetricAggregationValidateForType(t *testing.T) {
cases := []struct {
name string
metricType metrictypes.Type
spaceAggregation metrictypes.SpaceAggregation
comparisonParam *metrictypes.ComparisonSpaceAggregationParam
wantErr bool
}{
{
name: "percentile on histogram is allowed",
metricType: metrictypes.HistogramType,
spaceAggregation: metrictypes.SpaceAggregationPercentile95,
wantErr: false,
},
{
name: "percentile on exponential histogram is allowed",
metricType: metrictypes.ExpHistogramType,
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
wantErr: false,
},
{
name: "percentile on summary is not allowed",
metricType: metrictypes.SummaryType,
spaceAggregation: metrictypes.SpaceAggregationPercentile95,
wantErr: true,
},
{
name: "percentile on sum is not allowed",
metricType: metrictypes.SumType,
spaceAggregation: metrictypes.SpaceAggregationPercentile95,
wantErr: true,
},
{
name: "non-percentile space aggregation on sum is allowed",
metricType: metrictypes.SumType,
spaceAggregation: metrictypes.SpaceAggregationSum,
wantErr: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
agg := MetricAggregation{
MetricName: "test_metric",
Type: tc.metricType,
SpaceAggregation: tc.spaceAggregation,
ComparisonSpaceAggregationParam: tc.comparisonParam,
}
err := agg.ValidateForType()
if tc.wantErr && err == nil {
t.Errorf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
}
}

View File

@@ -43,7 +43,7 @@ type PostableTag struct {
Value string `json:"value" required:"true"`
}
type GettableTag PostableTag
type GettableTag = PostableTag
func NewGettableTagFromTag(tag *Tag) *GettableTag {
return &GettableTag{Key: tag.Key, Value: tag.Value}

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