Compare commits

...

4 Commits

Author SHA1 Message Date
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
8 changed files with 518 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,207 @@
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"
)
// 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(),
},
}
}
// redactPanelQueries rebuilds the panel map with each query redacted, leaving the
// source spec untouched.
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 = redactQueryPluginSpec(query.Spec.Plugin.Kind, query.Spec.Plugin.Spec)
queries[i] = query
}
redacted.Spec.Queries = queries
panels[key] = &redacted
}
spec.Panels = panels
}
// redactQueryPluginSpec redacts a single panel query plugin spec. Composite specs
// hold their sub-queries as values; promql/clickhouse/formula/trace specs are pointers.
func redactQueryPluginSpec(kind QueryPluginKind, spec any) any {
switch kind {
case QueryKindComposite:
composite, ok := spec.(*qb.CompositeQuery)
if !ok || composite == nil {
return spec
}
queries := make([]qb.QueryEnvelope, len(composite.Queries))
for i, envelope := range composite.Queries {
envelope.Spec = redactQuery(envelope.Spec)
queries[i] = envelope
}
return &qb.CompositeQuery{Queries: queries}
case QueryKindBuilder:
builder, ok := spec.(*BuilderQuerySpec)
if !ok || builder == nil {
return spec
}
return &BuilderQuerySpec{Spec: redactQuery(builder.Spec)}
case QueryKindPromQL:
if s, ok := spec.(*qb.PromQuery); ok {
redacted := redactQuery(*s).(qb.PromQuery)
return &redacted
}
case QueryKindClickHouseSQL:
if s, ok := spec.(*qb.ClickHouseQuery); ok {
redacted := redactQuery(*s).(qb.ClickHouseQuery)
return &redacted
}
case QueryKindFormula:
if s, ok := spec.(*qb.QueryBuilderFormula); ok {
redacted := redactQuery(*s).(qb.QueryBuilderFormula)
return &redacted
}
case QueryKindTraceOperator:
if s, ok := spec.(*qb.QueryBuilderTraceOperator); ok {
redacted := redactQuery(*s).(qb.QueryBuilderTraceOperator)
return &redacted
}
}
return spec
}
// redactQuery returns a copy of a query value carrying only public-safe fields.
// Building up an allowlist (rather than clearing fields) fails closed: any new
// field is excluded until explicitly added here.
func redactQuery(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, Step: s.Step, Disabled: s.Disabled, Legend: s.Legend}
case qb.ClickHouseQuery:
return qb.ClickHouseQuery{Name: s.Name, Disabled: s.Disabled, Legend: s.Legend}
case qb.QueryBuilderFormula:
return qb.QueryBuilderFormula{Name: s.Name, Expression: s.Expression, Disabled: s.Disabled, Legend: s.Legend}
case qb.QueryBuilderTraceOperator:
return qb.QueryBuilderTraceOperator{
Name: s.Name,
Expression: s.Expression,
Aggregations: s.Aggregations,
GroupBy: s.GroupBy,
StepInterval: s.StepInterval,
Disabled: s.Disabled,
Legend: s.Legend,
}
}
return spec
}
// redactBuilderQuery keeps only display-relevant fields, dropping filter, having,
// order, limit, and any other query internals.
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,
StepInterval: q.StepInterval,
Disabled: q.Disabled,
Legend: q.Legend,
}
}
// GetPanelQuery builds a v5 QueryRangeRequest for the given panel. v2 panel queries
// are already native v5 shapes, so the request is assembled directly: a composite is
// used as-is, every other kind becomes a single-envelope composite.
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 := compositeQueryFromPlugin(query.Spec.Plugin)
if err != nil {
return nil, err
}
return &qb.QueryRangeRequest{
SchemaVersion: "v1",
Start: startTime,
End: endTime,
RequestType: query.Kind,
CompositeQuery: composite,
FormatOptions: &qb.FormatOptions{
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
},
}, nil
}
// compositeQueryFromPlugin turns a panel's single query plugin into a v5 composite
// query. The plugin spec is already a typed v5 value, so no JSON round-trip is needed.
func compositeQueryFromPlugin(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

@@ -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))
})
}