mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 05:20:33 +01:00
Compare commits
8 Commits
nv/schema-
...
nv/v2-publ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b32f00b9 | ||
|
|
76f8646c69 | ||
|
|
28c00e298a | ||
|
|
4592b12256 | ||
|
|
b990d40c5f | ||
|
|
95a0d7c035 | ||
|
|
e678728c61 | ||
|
|
42d3e7e0e4 |
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
209
pkg/types/dashboardtypes/perses_public_dashboard.go
Normal file
209
pkg/types/dashboardtypes/perses_public_dashboard.go
Normal 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}}}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
117
pkg/types/dashboardtypes/public_dashboard_test.go
Normal file
117
pkg/types/dashboardtypes/public_dashboard_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user