Compare commits

...

8 Commits

Author SHA1 Message Date
Naman Verma
99b32f00b9 fix: add fill gaps to query 2026-06-16 02:30:59 +05:30
Naman Verma
76f8646c69 test: unit tests for GetPanelQuery 2026-06-16 02:18:01 +05:30
Naman Verma
28c00e298a chore: rename method name 2026-06-16 02:01:40 +05:30
Naman Verma
4592b12256 fix: remove fields that v1 also removes when redacting 2026-06-16 01:53:33 +05:30
Naman Verma
b990d40c5f chore: trim comments 2026-06-16 00:23:41 +05:30
Naman Verma
95a0d7c035 fix: fill fields that were in the data blob in v1 2026-06-15 23:04:21 +05:30
Naman Verma
e678728c61 fix: remove duplicate call to GetDashboardByPublicIDV2 in GetPublicWidgetQueryRangeV2 2026-06-15 22:31:45 +05:30
Naman Verma
42d3e7e0e4 feat: add first draft of v2 public dashboard apis 2026-06-15 15:00:44 +05:30
10 changed files with 1057 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ type module struct {
settings factory.ScopedProviderSettings
querier querier.Querier
licensing licensing.Licensing
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
@@ -41,6 +42,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
settings: scopedProviderSettings,
querier: querier,
licensing: licensing,
tagModule: tagModule,
}
}
@@ -132,6 +134,49 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
if err != nil {
return nil, err
}
return storableDashboard.ToDashboardV2(tags)
}
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "dashboard",
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
})
dashboard, err := module.GetDashboardByPublicIDV2(ctx, id)
if err != nil {
return nil, err
}
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
if err != nil {
return nil, err
}
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
if err != nil {
return nil, err
}
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
if err != nil {
return nil, err
}
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {

View File

@@ -336,5 +336,61 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/public/dashboards/{id}", handler.New(provider.authzMiddleware.CheckWithoutClaims(
provider.dashboardHandler.GetPublicDataV2,
authtypes.Relation{Verb: coretypes.VerbRead},
coretypes.ResourceMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
}, []string{}), handler.OpenAPIDef{
ID: "GetPublicDashboardDataV2",
Tags: []string{"dashboard"},
Summary: "Get public dashboard data (v2)",
Description: "This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettablePublicDashboardDataV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/public/dashboards/{id}/panels/{key}/query_range", handler.New(provider.authzMiddleware.CheckWithoutClaims(
provider.dashboardHandler.GetPublicWidgetQueryRangeV2,
authtypes.Relation{Verb: coretypes.VerbRead},
coretypes.ResourceMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
}, []string{}), handler.OpenAPIDef{
ID: "GetPublicDashboardPanelQueryRangeV2",
Tags: []string{"dashboard"},
Summary: "Get query range result (v2)",
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
Request: nil,
RequestContentType: "",
Response: new(querybuildertypesv5.QueryRangeResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -78,6 +78,12 @@ type Module interface {
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
// get the v2 dashboard data by public dashboard id
GetDashboardByPublicIDV2(context.Context, valuer.UUID) (*dashboardtypes.DashboardV2, error)
// gets the query results by panel key and public shared id for a v2 dashboard
GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error)
}
type Handler interface {
@@ -89,6 +95,10 @@ type Handler interface {
GetPublicWidgetQueryRange(http.ResponseWriter, *http.Request)
GetPublicDataV2(http.ResponseWriter, *http.Request)
GetPublicWidgetQueryRangeV2(http.ResponseWriter, *http.Request)
UpdatePublic(http.ResponseWriter, *http.Request)
DeletePublic(http.ResponseWriter, *http.Request)

View File

@@ -247,6 +247,14 @@ func (module *module) GetPublicWidgetQueryRange(context.Context, valuer.UUID, ui
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) GetDashboardByPublicIDV2(_ context.Context, _ valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) GetPublicWidgetQueryRangeV2(context.Context, valuer.UUID, string, string, string) (*qbtypes.QueryRangeResponse, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) GetPublicDashboardSelectorsAndOrg(_ context.Context, _ valuer.UUID, _ []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
return nil, valuer.UUID{}, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}

View File

@@ -344,3 +344,53 @@ func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) GetPublicDataV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.GetDashboardByPublicIDV2(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
publicDashboard, err := handler.module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewPublicDashboardDataFromDashboardV2(dashboard, publicDashboard))
}
func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
panelKey, ok := mux.Vars(r)["key"]
if !ok || panelKey == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "panel key is missing from the path"))
return
}
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, queryRangeResults)
}

View File

@@ -0,0 +1,209 @@
package dashboardtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
)
// ════════════════════════════════════════════════════════════════════════
// Gettable
// ════════════════════════════════════════════════════════════════════════
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
type GettablePublicDashboardDataV2 struct {
Dashboard *GettableDashboardV2 `json:"dashboard"`
PublicDashboard *GettablePublicDasbhboard `json:"publicDashboard"`
}
// NewPublicDashboardDataFromDashboardV2 builds the anonymous v2 payload: panel queries
// are redacted, and only the body fields v1 exposed (name, metadata, tags, spec) are set.
func NewPublicDashboardDataFromDashboardV2(dashboard *DashboardV2, publicDashboard *PublicDashboard) *GettablePublicDashboardDataV2 {
spec := dashboard.Spec
redactPanelQueries(&spec)
return &GettablePublicDashboardDataV2{
Dashboard: &GettableDashboardV2{
DashboardV2MetadataBase: dashboard.DashboardV2MetadataBase,
Name: dashboard.Name,
Tags: tagtypes.NewGettableTagsFromTags(dashboard.Tags),
Spec: spec,
},
PublicDashboard: &GettablePublicDasbhboard{
TimeRangeEnabled: publicDashboard.TimeRangeEnabled,
DefaultTimeRange: publicDashboard.DefaultTimeRange,
PublicPath: publicDashboard.PublicPath(),
},
}
}
// ════════════════════════════════════════════════════════════════════════
// Redaction
// ════════════════════════════════════════════════════════════════════════
func redactPanelQueries(spec *DashboardSpec) {
panels := make(map[string]*Panel, len(spec.Panels))
for key, panel := range spec.Panels {
if panel == nil {
panels[key] = nil
continue
}
redacted := *panel
queries := make([]Query, len(redacted.Spec.Queries))
for i, query := range redacted.Spec.Queries {
query.Spec.Plugin.Spec = redactQuery(query.Spec.Plugin.Spec)
queries[i] = query
}
redacted.Spec.Queries = queries
panels[key] = &redacted
}
spec.Panels = panels
}
func redactQuery(spec any) any {
switch s := spec.(type) {
case *qb.CompositeQuery:
if s == nil {
return spec
}
queries := make([]qb.QueryEnvelope, len(s.Queries))
for i, envelope := range s.Queries {
envelope.Spec = redactLeafQuery(envelope.Spec)
queries[i] = envelope
}
return &qb.CompositeQuery{Queries: queries}
case *BuilderQuerySpec:
if s == nil {
return spec
}
return &BuilderQuerySpec{Spec: redactLeafQuery(s.Spec)}
case *qb.PromQuery:
return redactQueryPtr(s)
case *qb.ClickHouseQuery:
return redactQueryPtr(s)
case *qb.QueryBuilderFormula:
return redactQueryPtr(s)
case *qb.QueryBuilderTraceOperator:
return redactQueryPtr(s)
}
return spec
}
func redactQueryPtr[T any](s *T) any {
if s == nil {
return s
}
redacted := redactLeafQuery(*s).(T)
return &redacted
}
func redactLeafQuery(spec any) any {
switch s := spec.(type) {
case qb.QueryBuilderQuery[qb.LogAggregation]:
return redactBuilderQuery(s)
case qb.QueryBuilderQuery[qb.MetricAggregation]:
return redactBuilderQuery(s)
case qb.QueryBuilderQuery[qb.TraceAggregation]:
return redactBuilderQuery(s)
case qb.PromQuery:
return qb.PromQuery{Name: s.Name, Legend: s.Legend}
case qb.ClickHouseQuery:
return qb.ClickHouseQuery{Name: s.Name, Legend: s.Legend}
case qb.QueryBuilderFormula:
return qb.QueryBuilderFormula{Name: s.Name, Expression: s.Expression, Legend: s.Legend}
case qb.QueryBuilderTraceOperator:
return qb.QueryBuilderTraceOperator{
Name: s.Name,
Expression: s.Expression,
Aggregations: s.Aggregations,
GroupBy: s.GroupBy,
Legend: s.Legend,
}
}
return spec
}
func redactBuilderQuery[T any](q qb.QueryBuilderQuery[T]) qb.QueryBuilderQuery[T] {
return qb.QueryBuilderQuery[T]{
Name: q.Name,
Signal: q.Signal,
Source: q.Source,
Aggregations: q.Aggregations,
GroupBy: q.GroupBy,
Legend: q.Legend,
}
}
// ════════════════════════════════════════════════════════════════════════
// Panel query
// ════════════════════════════════════════════════════════════════════════
func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string) (*qb.QueryRangeRequest, error) {
panel, ok := d.Spec.Panels[panelKey]
if !ok || panel == nil {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidInput, "panel with key %q doesn't exist", panelKey)
}
// Validator guarantees exactly one query per panel.
if len(panel.Spec.Queries) != 1 {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "panel %q must have exactly one query", panelKey)
}
query := panel.Spec.Queries[0]
composite, err := buildV5CompositeQueryFromPlugin(query.Spec.Plugin)
if err != nil {
return nil, err
}
// fillGaps lives on the panel visualization; only timeseries and bar chart carry it.
fillGaps := false
switch panelSpec := panel.Spec.Plugin.Spec.(type) {
case *TimeSeriesPanelSpec:
if panelSpec != nil {
fillGaps = panelSpec.Visualization.FillSpans
}
case *BarChartPanelSpec:
if panelSpec != nil {
fillGaps = panelSpec.Visualization.FillSpans
}
}
return &qb.QueryRangeRequest{
SchemaVersion: "v1",
Start: startTime,
End: endTime,
RequestType: query.Kind,
CompositeQuery: composite,
FormatOptions: &qb.FormatOptions{
FillGaps: fillGaps,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
},
}, nil
}
func buildV5CompositeQueryFromPlugin(plugin QueryPlugin) (qb.CompositeQuery, error) {
switch spec := plugin.Spec.(type) {
case *qb.CompositeQuery:
if spec == nil {
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "composite query is empty")
}
return *spec, nil
case *BuilderQuerySpec:
if spec == nil {
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "builder query is empty")
}
return wrapEnvelope(qb.QueryTypeBuilder, spec.Spec), nil
case *qb.PromQuery:
return wrapEnvelope(qb.QueryTypePromQL, *spec), nil
case *qb.ClickHouseQuery:
return wrapEnvelope(qb.QueryTypeClickHouseSQL, *spec), nil
case *qb.QueryBuilderFormula:
return wrapEnvelope(qb.QueryTypeFormula, *spec), nil
case *qb.QueryBuilderTraceOperator:
return wrapEnvelope(qb.QueryTypeTraceOperator, *spec), nil
}
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "unsupported query kind %q", plugin.Kind)
}
func wrapEnvelope(queryType qb.QueryType, spec any) qb.CompositeQuery {
return qb.CompositeQuery{Queries: []qb.QueryEnvelope{{Type: queryType, Spec: spec}}}
}

View File

@@ -0,0 +1,331 @@
package dashboardtypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDashboardV2GetPanelQuery(t *testing.T) {
t.Run("returns error when the panel does not exist", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
},
},
}
_, err := dashboard.GetPanelQuery(1, 2, "wrongPanelKey")
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
})
t.Run("returns error when the panel is nil", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": nil,
},
},
}
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
})
t.Run("returns error unless the panel has exactly one query", func(t *testing.T) {
cases := []struct {
description string
queries []Query
}{
{description: "zero queries", queries: nil},
{description: "two queries", queries: []Query{{}, {}}},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{Queries: tc.queries},
},
},
},
}
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
})
}
})
t.Run("builds a single-envelope request for a builder query", func(t *testing.T) {
builder := qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: builder},
},
},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(100, 200, "panel-1")
require.NoError(t, err)
assert.Equal(t, "v1", req.SchemaVersion)
assert.Equal(t, uint64(100), req.Start)
assert.Equal(t, uint64(200), req.End)
assert.Equal(t, qb.RequestTypeTimeSeries, req.RequestType)
require.Len(t, req.CompositeQuery.Queries, 1)
assert.Equal(t, qb.QueryTypeBuilder, req.CompositeQuery.Queries[0].Type)
assert.Equal(t, builder, req.CompositeQuery.Queries[0].Spec)
require.NotNil(t, req.FormatOptions)
assert.False(t, req.FormatOptions.FormatTableResultForUI)
})
t.Run("uses a composite query as-is", func(t *testing.T) {
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
{Type: qb.QueryTypePromQL, Spec: qb.PromQuery{Name: "B"}},
}}
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindComposite,
Spec: composite,
},
},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
assert.Equal(t, composite.Queries, req.CompositeQuery.Queries)
})
t.Run("wraps a leaf query in a single typed envelope", func(t *testing.T) {
cases := []struct {
description string
plugin QueryPlugin
expectedType qb.QueryType
}{
{
description: "promql",
plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &qb.PromQuery{Name: "A", Query: "up"}},
expectedType: qb.QueryTypePromQL,
},
{
description: "clickhouse",
plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &qb.ClickHouseQuery{Name: "A", Query: "SELECT 1"}},
expectedType: qb.QueryTypeClickHouseSQL,
},
{
description: "formula",
plugin: QueryPlugin{Kind: QueryKindFormula, Spec: &qb.QueryBuilderFormula{Name: "F1", Expression: "A / B"}},
expectedType: qb.QueryTypeFormula,
},
{
description: "trace operator",
plugin: QueryPlugin{Kind: QueryKindTraceOperator, Spec: &qb.QueryBuilderTraceOperator{Name: "T1", Expression: "A => B"}},
expectedType: qb.QueryTypeTraceOperator,
},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{Plugin: tc.plugin},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.Len(t, req.CompositeQuery.Queries, 1)
assert.Equal(t, tc.expectedType, req.CompositeQuery.Queries[0].Type)
})
}
})
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTable},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
cases := []struct {
description string
panelPlugin PanelPlugin
expectedFillGaps bool
}{
{
description: "timeseries with fillSpans enabled",
panelPlugin: PanelPlugin{Kind: PanelKindTimeSeries, Spec: &TimeSeriesPanelSpec{Visualization: TimeSeriesVisualization{FillSpans: true}}},
expectedFillGaps: true,
},
{
description: "timeseries with fillSpans disabled",
panelPlugin: PanelPlugin{Kind: PanelKindTimeSeries, Spec: &TimeSeriesPanelSpec{Visualization: TimeSeriesVisualization{FillSpans: false}}},
expectedFillGaps: false,
},
{
description: "bar chart with fillSpans enabled",
panelPlugin: PanelPlugin{Kind: PanelKindBarChart, Spec: &BarChartPanelSpec{Visualization: BarChartVisualization{FillSpans: true}}},
expectedFillGaps: true,
},
{
description: "table panel has no fillSpans",
panelPlugin: PanelPlugin{Kind: PanelKindTable, Spec: &TablePanelSpec{}},
expectedFillGaps: false,
},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: tc.panelPlugin,
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.Equal(t, tc.expectedFillGaps, req.FormatOptions.FillGaps)
})
}
})
t.Run("returns error for an unsupported plugin spec", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: "not-a-query",
},
},
},
},
},
},
},
},
}
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
})
}

View File

@@ -0,0 +1,206 @@
package dashboardtypes
import (
"testing"
"time"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRedactLeafQuery(t *testing.T) {
t.Run("builder query drops filter, having, limit and keeps display fields", func(t *testing.T) {
input := qb.QueryBuilderQuery[qb.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qb.MetricAggregation{{MetricName: "system_cpu_usage"}},
GroupBy: []qb.GroupByKey{{}},
Legend: "cpu",
Disabled: true,
StepInterval: qb.Step{Duration: time.Minute},
Filter: &qb.Filter{Expression: "service.name = 'checkout'"},
Having: &qb.Having{},
Limit: 100,
}
redacted, ok := redactLeafQuery(input).(qb.QueryBuilderQuery[qb.MetricAggregation])
require.True(t, ok)
// dropped (not in the v1 whitelist)
assert.Nil(t, redacted.Filter)
assert.Nil(t, redacted.Having)
assert.Zero(t, redacted.Limit)
assert.Zero(t, redacted.StepInterval)
assert.False(t, redacted.Disabled)
// kept (display)
assert.Equal(t, "A", redacted.Name)
assert.Equal(t, telemetrytypes.SignalMetrics, redacted.Signal)
assert.Equal(t, "cpu", redacted.Legend)
require.Len(t, redacted.Aggregations, 1)
assert.Equal(t, "system_cpu_usage", redacted.Aggregations[0].MetricName)
assert.Len(t, redacted.GroupBy, 1)
})
t.Run("promql query drops the raw query, step and disabled", func(t *testing.T) {
redacted, ok := redactLeafQuery(qb.PromQuery{
Name: "A",
Query: "sum(rate(http_requests_total[5m]))",
Step: qb.Step{Duration: time.Minute},
Disabled: true,
Legend: "rps",
}).(qb.PromQuery)
require.True(t, ok)
assert.Empty(t, redacted.Query)
assert.Zero(t, redacted.Step)
assert.False(t, redacted.Disabled)
assert.Equal(t, "A", redacted.Name)
assert.Equal(t, "rps", redacted.Legend)
})
t.Run("clickhouse query drops the raw query string", func(t *testing.T) {
redacted, ok := redactLeafQuery(qb.ClickHouseQuery{
Name: "A",
Query: "SELECT * FROM signoz_logs WHERE user = 'admin'",
Legend: "logs",
}).(qb.ClickHouseQuery)
require.True(t, ok)
assert.Empty(t, redacted.Query)
assert.Equal(t, "A", redacted.Name)
assert.Equal(t, "logs", redacted.Legend)
})
t.Run("formula keeps its expression but drops limit and having", func(t *testing.T) {
redacted, ok := redactLeafQuery(qb.QueryBuilderFormula{
Name: "F1",
Expression: "A / B",
Legend: "ratio",
Limit: 50,
Having: &qb.Having{},
}).(qb.QueryBuilderFormula)
require.True(t, ok)
assert.Equal(t, "A / B", redacted.Expression)
assert.Equal(t, "F1", redacted.Name)
assert.Equal(t, "ratio", redacted.Legend)
assert.Zero(t, redacted.Limit)
assert.Nil(t, redacted.Having)
})
t.Run("trace operator drops filter but keeps expression and aggregations", func(t *testing.T) {
redacted, ok := redactLeafQuery(qb.QueryBuilderTraceOperator{
Name: "T1",
Expression: "A => B",
Aggregations: []qb.TraceAggregation{{}},
Legend: "spans",
Filter: &qb.Filter{Expression: "http.status_code = 500"},
ReturnSpansFrom: "A",
Limit: 10,
}).(qb.QueryBuilderTraceOperator)
require.True(t, ok)
assert.Nil(t, redacted.Filter)
assert.Empty(t, redacted.ReturnSpansFrom)
assert.Zero(t, redacted.Limit)
assert.Equal(t, "A => B", redacted.Expression)
assert.Equal(t, "T1", redacted.Name)
assert.Len(t, redacted.Aggregations, 1)
})
t.Run("unknown value is returned unchanged", func(t *testing.T) {
assert.Equal(t, "passthrough", redactLeafQuery("passthrough"))
})
}
func TestRedactQueryPluginWrappers(t *testing.T) {
t.Run("builder plugin pointer is redacted and stays a pointer", func(t *testing.T) {
plugin := &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.LogAggregation]{
Name: "A",
Filter: &qb.Filter{Expression: "body contains 'secret'"},
}}
result, ok := redactQuery(plugin).(*BuilderQuerySpec)
require.True(t, ok)
builder, ok := result.Spec.(qb.QueryBuilderQuery[qb.LogAggregation])
require.True(t, ok)
assert.Nil(t, builder.Filter)
assert.Equal(t, "A", builder.Name)
})
t.Run("composite plugin redacts every sub-query envelope", func(t *testing.T) {
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A", Filter: &qb.Filter{Expression: "x = 1"}}},
{Type: qb.QueryTypePromQL, Spec: qb.PromQuery{Name: "B", Query: "up"}},
}}
result, ok := redactQuery(composite).(*qb.CompositeQuery)
require.True(t, ok)
require.Len(t, result.Queries, 2)
builder, ok := result.Queries[0].Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
require.True(t, ok)
assert.Nil(t, builder.Filter)
prom, ok := result.Queries[1].Spec.(qb.PromQuery)
require.True(t, ok)
assert.Empty(t, prom.Query)
})
t.Run("promql plugin pointer drops the raw query", func(t *testing.T) {
result, ok := redactQuery(&qb.PromQuery{Name: "A", Query: "up"}).(*qb.PromQuery)
require.True(t, ok)
assert.Empty(t, result.Query)
assert.Equal(t, "A", result.Name)
})
t.Run("nil composite pointer is returned unchanged without panicking", func(t *testing.T) {
var nilComposite *qb.CompositeQuery
assert.Equal(t, nilComposite, redactQuery(nilComposite))
})
t.Run("unknown plugin spec is returned unchanged", func(t *testing.T) {
assert.Equal(t, 42, redactQuery(42))
})
}
func TestRedactPanelQueries(t *testing.T) {
t.Run("redacts panel queries without mutating the source spec", func(t *testing.T) {
sourcePlugin := &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{
Name: "A",
Filter: &qb.Filter{Expression: "service.name = 'payments'"},
}}
sourcePanel := &Panel{Spec: PanelSpec{
Queries: []Query{{Spec: QuerySpec{Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: sourcePlugin}}}},
}}
spec := DashboardSpec{Panels: map[string]*Panel{"panel-1": sourcePanel}}
redactPanelQueries(&spec)
// redacted output has no filter
redactedPanel := spec.Panels["panel-1"]
require.NotNil(t, redactedPanel)
redactedBuilder := redactedPanel.Spec.Queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec).Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
assert.Nil(t, redactedBuilder.Filter)
// source is untouched: original plugin still carries the filter
sourceBuilder := sourcePlugin.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
require.NotNil(t, sourceBuilder.Filter)
assert.Equal(t, "service.name = 'payments'", sourceBuilder.Filter.Expression)
assert.NotSame(t, sourcePanel, redactedPanel)
})
t.Run("preserves nil panels without panicking", func(t *testing.T) {
spec := DashboardSpec{Panels: map[string]*Panel{"panel-1": nil}}
redactPanelQueries(&spec)
panel, ok := spec.Panels["panel-1"]
assert.True(t, ok)
assert.Nil(t, panel)
})
}

View File

@@ -2,6 +2,7 @@ package dashboardtypes
import (
"encoding/json"
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -212,6 +213,30 @@ func (typ *PublicDashboard) PublicPath() string {
return "/public/dashboard/" + typ.ID.StringValue()
}
// ResolveTimeRange returns the [start, end] window in epoch millis for a public
// widget/panel query: the caller-supplied range when the dashboard allows it,
// otherwise now minus the configured default range.
func (typ *PublicDashboard) ResolveTimeRange(startTimeRaw, endTimeRaw string) (uint64, uint64, error) {
if typ.TimeRangeEnabled {
startTime, err := strconv.ParseUint(startTimeRaw, 10, 64)
if err != nil {
return 0, 0, errors.New(errors.TypeInvalidInput, ErrCodePublicDashboardInvalidInput, "invalid startTime")
}
endTime, err := strconv.ParseUint(endTimeRaw, 10, 64)
if err != nil {
return 0, 0, errors.New(errors.TypeInvalidInput, ErrCodePublicDashboardInvalidInput, "invalid endTime")
}
return startTime, endTime, nil
}
timeRange, err := time.ParseDuration(typ.DefaultTimeRange)
if err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "stored defaultTimeRange %q is not a valid duration", typ.DefaultTimeRange)
}
now := time.Now()
return uint64(now.Add(-timeRange).UnixMilli()), uint64(now.UnixMilli()), nil
}
func (typ *PostablePublicDashboard) UnmarshalJSON(data []byte) error {
type alias PostablePublicDashboard
var temp alias

View File

@@ -0,0 +1,117 @@
package dashboardtypes
import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublicDashboardResolveTimeRange(t *testing.T) {
t.Run("returns the explicit range when time range is enabled", func(t *testing.T) {
cases := []struct {
description string
startTimeRaw string
endTimeRaw string
expectedStart uint64
expectedEnd uint64
}{
{
description: "valid epoch millis",
startTimeRaw: "1700000000000",
endTimeRaw: "1700000600000",
expectedStart: 1700000000000,
expectedEnd: 1700000600000,
},
{
description: "zero start is allowed",
startTimeRaw: "0",
endTimeRaw: "1700000600000",
expectedStart: 0,
expectedEnd: 1700000600000,
},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
publicDashboard := &PublicDashboard{TimeRangeEnabled: true}
startTime, endTime, err := publicDashboard.ResolveTimeRange(tc.startTimeRaw, tc.endTimeRaw)
require.NoError(t, err)
assert.Equal(t, tc.expectedStart, startTime)
assert.Equal(t, tc.expectedEnd, endTime)
})
}
})
t.Run("rejects an invalid explicit range when time range is enabled", func(t *testing.T) {
cases := []struct {
description string
startTimeRaw string
endTimeRaw string
}{
{description: "non-numeric startTime", startTimeRaw: "abc", endTimeRaw: "1700000600000"},
{description: "empty startTime", startTimeRaw: "", endTimeRaw: "1700000600000"},
{description: "negative startTime", startTimeRaw: "-1", endTimeRaw: "1700000600000"},
{description: "non-numeric endTime", startTimeRaw: "1700000000000", endTimeRaw: "xyz"},
{description: "empty endTime", startTimeRaw: "1700000000000", endTimeRaw: ""},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
publicDashboard := &PublicDashboard{TimeRangeEnabled: true}
_, _, err := publicDashboard.ResolveTimeRange(tc.startTimeRaw, tc.endTimeRaw)
assert.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
})
}
})
t.Run("derives the range from now and the default when time range is disabled", func(t *testing.T) {
cases := []struct {
description string
defaultTimeRange string
expectedWidthMS uint64
}{
{description: "one hour", defaultTimeRange: "1h", expectedWidthMS: uint64(time.Hour.Milliseconds())},
{description: "thirty minutes", defaultTimeRange: "30m", expectedWidthMS: uint64((30 * time.Minute).Milliseconds())},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: tc.defaultTimeRange}
before := uint64(time.Now().UnixMilli())
startTime, endTime, err := publicDashboard.ResolveTimeRange("ignored", "ignored")
after := uint64(time.Now().UnixMilli())
require.NoError(t, err)
// end is "now"; both bounds share the same instant, so the width is exact.
assert.GreaterOrEqual(t, endTime, before)
assert.LessOrEqual(t, endTime, after)
assert.Equal(t, tc.expectedWidthMS, endTime-startTime)
})
}
})
t.Run("ignores caller-supplied bounds when time range is disabled", func(t *testing.T) {
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: "1h"}
startTime, endTime, err := publicDashboard.ResolveTimeRange("123", "456")
require.NoError(t, err)
assert.NotEqual(t, uint64(123), startTime)
assert.NotEqual(t, uint64(456), endTime)
assert.Equal(t, uint64(time.Hour.Milliseconds()), endTime-startTime)
})
t.Run("returns an internal error for an unparseable stored default range", func(t *testing.T) {
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: "not-a-duration"}
_, _, err := publicDashboard.ResolveTimeRange("", "")
assert.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInternal))
})
}