From fc374f93a3566af3943bfe5b3fb87123983c2e24 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Wed, 14 Jun 2023 17:35:01 -0300 Subject: [PATCH] PublicDashboards: Support timezone on query API (#68560) --- .../publicdashboards/models/models.go | 30 +-- .../publicdashboards/models/models_test.go | 63 ------- .../publicdashboards/service/query.go | 62 +++++- .../publicdashboards/service/query_test.go | 176 +++++++++++++++++- .../validation/validation_test.go | 8 +- .../services/PublicDashboardDataSource.ts | 11 +- 6 files changed, 253 insertions(+), 97 deletions(-) diff --git a/pkg/services/publicdashboards/models/models.go b/pkg/services/publicdashboards/models/models.go index 47b32e6ded0..31f6d725af5 100644 --- a/pkg/services/publicdashboards/models/models.go +++ b/pkg/services/publicdashboards/models/models.go @@ -2,12 +2,9 @@ package models import ( "encoding/json" - "strconv" "time" "github.com/grafana/grafana/pkg/kinds/dashboard" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/tsdb/legacydata" ) // PublicDashboardErr represents a dashboard error. @@ -127,25 +124,6 @@ func (ts *TimeSettings) ToDB() ([]byte, error) { return json.Marshal(ts) } -// BuildTimeSettings build time settings object using selected values if enabled and are valid or dashboard default values -func (pd PublicDashboard) BuildTimeSettings(dashboard *dashboards.Dashboard, reqDTO PublicDashboardQueryDTO) TimeSettings { - from := dashboard.Data.GetPath("time", "from").MustString() - to := dashboard.Data.GetPath("time", "to").MustString() - - if pd.TimeSelectionEnabled { - from = reqDTO.TimeRange.From - to = reqDTO.TimeRange.To - } - - timeRange := legacydata.NewDataTimeRange(from, to) - - // Were using epoch ms because this is used to build a MetricRequest, which is used by query caching, which expected the time range in epoch milliseconds. - return TimeSettings{ - From: strconv.FormatInt(timeRange.GetFromAsMsEpoch(), 10), - To: strconv.FormatInt(timeRange.GetToAsMsEpoch(), 10), - } -} - // DTO for transforming user input in the api type SavePublicDashboardDTO struct { DashboardUid string @@ -153,11 +131,17 @@ type SavePublicDashboardDTO struct { PublicDashboard *PublicDashboardDTO } +type TimeRangeDTO struct { + From string + To string + Timezone string +} + type PublicDashboardQueryDTO struct { IntervalMs int64 MaxDataPoints int64 QueryCachingTTL int64 - TimeRange TimeSettings + TimeRange TimeRangeDTO } type AnnotationsQueryDTO struct { diff --git a/pkg/services/publicdashboards/models/models_test.go b/pkg/services/publicdashboards/models/models_test.go index fe60b4aa9c0..b8762ad007c 100644 --- a/pkg/services/publicdashboards/models/models_test.go +++ b/pkg/services/publicdashboards/models/models_test.go @@ -1,74 +1,11 @@ package models import ( - "strconv" "testing" - "time" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal" "github.com/stretchr/testify/assert" ) func TestPublicDashboardTableName(t *testing.T) { assert.Equal(t, "dashboard_public", PublicDashboard{}.TableName()) } - -func TestBuildTimeSettings(t *testing.T) { - var dashboardData = simplejson.NewFromAny(map[string]interface{}{"time": map[string]interface{}{"from": "2022-09-01T00:00:00.000Z", "to": "2022-09-01T12:00:00.000Z"}}) - defaultFromMs, defaultToMs := internal.GetTimeRangeFromDashboard(t, dashboardData) - - selectionFromMs := strconv.FormatInt(time.Now().UnixMilli(), 10) - selectionToMs := strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10) - - testCases := []struct { - name string - dashboard *dashboards.Dashboard - pubdash *PublicDashboard - timeResult TimeSettings - reqDTO PublicDashboardQueryDTO - }{ - { - name: "should use dashboard time if pubdash time empty", - dashboard: &dashboards.Dashboard{Data: dashboardData}, - pubdash: &PublicDashboard{TimeSelectionEnabled: false}, - timeResult: TimeSettings{ - From: defaultFromMs, - To: defaultToMs, - }, - reqDTO: PublicDashboardQueryDTO{}, - }, - { - name: "should use dashboard time when time selection is disabled", - dashboard: &dashboards.Dashboard{Data: dashboardData}, - pubdash: &PublicDashboard{TimeSelectionEnabled: false, TimeSettings: &TimeSettings{From: "now-12", To: "now"}}, - timeResult: TimeSettings{ - From: defaultFromMs, - To: defaultToMs, - }, - reqDTO: PublicDashboardQueryDTO{}, - }, - { - name: "should use selected values if time selection is enabled", - dashboard: &dashboards.Dashboard{Data: dashboardData}, - pubdash: &PublicDashboard{TimeSelectionEnabled: true, TimeSettings: &TimeSettings{From: "now-12", To: "now"}}, - reqDTO: PublicDashboardQueryDTO{ - TimeRange: TimeSettings{ - From: selectionFromMs, - To: selectionToMs, - }, - }, - timeResult: TimeSettings{ - From: selectionFromMs, - To: selectionToMs, - }, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.timeResult, test.pubdash.BuildTimeSettings(test.dashboard, test.reqDTO)) - }) - } -} diff --git a/pkg/services/publicdashboards/service/query.go b/pkg/services/publicdashboards/service/query.go index 184e83d5916..ae186ef7653 100644 --- a/pkg/services/publicdashboards/service/query.go +++ b/pkg/services/publicdashboards/service/query.go @@ -2,6 +2,8 @@ package service import ( "context" + "strconv" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/api/dtos" @@ -14,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tsdb/grafanads" + "github.com/grafana/grafana/pkg/tsdb/legacydata" ) // FindAnnotations returns annotations for a public dashboard @@ -106,7 +109,6 @@ func (pd *PublicDashboardServiceImpl) GetMetricRequest(ctx context.Context, dash } metricReqDTO, err := pd.buildMetricRequest( - ctx, dashboard, publicDashboard, panelId, @@ -151,7 +153,7 @@ func (pd *PublicDashboardServiceImpl) GetQueryDataResponse(ctx context.Context, } // buildMetricRequest merges public dashboard parameters with dashboard and returns a metrics request to be sent to query backend -func (pd *PublicDashboardServiceImpl) buildMetricRequest(ctx context.Context, dashboard *dashboards.Dashboard, publicDashboard *models.PublicDashboard, panelId int64, reqDTO models.PublicDashboardQueryDTO) (dtos.MetricRequest, error) { +func (pd *PublicDashboardServiceImpl) buildMetricRequest(dashboard *dashboards.Dashboard, publicDashboard *models.PublicDashboard, panelId int64, reqDTO models.PublicDashboardQueryDTO) (dtos.MetricRequest, error) { // group queries by panel queriesByPanel := groupQueriesByPanelId(dashboard.Data) queries, ok := queriesByPanel[panelId] @@ -159,7 +161,7 @@ func (pd *PublicDashboardServiceImpl) buildMetricRequest(ctx context.Context, da return dtos.MetricRequest{}, models.ErrPanelNotFound.Errorf("buildMetricRequest: public dashboard panel not found") } - ts := publicDashboard.BuildTimeSettings(dashboard, reqDTO) + ts := buildTimeSettings(dashboard, reqDTO, publicDashboard) // determine safe resolution to query data at safeInterval, safeResolution := pd.getSafeIntervalAndMaxDataPoints(reqDTO, ts) @@ -319,3 +321,57 @@ func sanitizeMetadataFromQueryData(res *backend.QueryDataResponse) { } } } + +// NewDataTimeRange declared to be able to stub this function in tests +var NewDataTimeRange = legacydata.NewDataTimeRange + +// BuildTimeSettings build time settings object using selected values if enabled and are valid or dashboard default values +func buildTimeSettings(d *dashboards.Dashboard, reqDTO models.PublicDashboardQueryDTO, pd *models.PublicDashboard) models.TimeSettings { + from, to, timezone := getTimeRangeValuesOrDefault(reqDTO, d, pd.TimeSelectionEnabled) + + timeRange := NewDataTimeRange(from, to) + + timeFrom, _ := timeRange.ParseFrom( + legacydata.WithLocation(timezone), + ) + timeTo, _ := timeRange.ParseTo( + legacydata.WithLocation(timezone), + ) + timeToAsEpoch := timeTo.UnixMilli() + timeFromAsEpoch := timeFrom.UnixMilli() + + // Were using epoch ms because this is used to build a MetricRequest, which is used by query caching, which want the time range in epoch milliseconds. + return models.TimeSettings{ + From: strconv.FormatInt(timeFromAsEpoch, 10), + To: strconv.FormatInt(timeToAsEpoch, 10), + } +} + +// returns from, to and timezone from the request if the timeSelection is enabled or the dashboard default values +func getTimeRangeValuesOrDefault(reqDTO models.PublicDashboardQueryDTO, d *dashboards.Dashboard, timeSelectionEnabled bool) (string, string, *time.Location) { + from := d.Data.GetPath("time", "from").MustString() + to := d.Data.GetPath("time", "to").MustString() + dashboardTimezone := d.Data.GetPath("timezone").MustString() + + // we use the values from the request if the time selection is enabled and the values are valid + if timeSelectionEnabled { + if reqDTO.TimeRange.From != "" && reqDTO.TimeRange.To != "" { + from = reqDTO.TimeRange.From + to = reqDTO.TimeRange.To + } + + if reqDTO.TimeRange.Timezone != "" { + if userTimezone, err := time.LoadLocation(reqDTO.TimeRange.Timezone); err == nil { + return from, to, userTimezone + } + } + } + + // if the dashboardTimezone is blank or there is an error default is UTC + timezone, err := time.LoadLocation(dashboardTimezone) + if err != nil { + return from, to, time.UTC + } + + return from, to, timezone +} diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 1e19916f258..8e52774b124 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -3,7 +3,9 @@ package service import ( "context" "errors" + "strconv" "testing" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -26,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/assert" @@ -1229,7 +1232,6 @@ func TestBuildMetricRequest(t *testing.T) { t.Run("extracts queries from provided dashboard", func(t *testing.T) { reqDTO, err := service.buildMetricRequest( - context.Background(), publicDashboard, publicDashboardPD, 1, @@ -1280,7 +1282,6 @@ func TestBuildMetricRequest(t *testing.T) { t.Run("returns an error when panel missing", func(t *testing.T) { _, err := service.buildMetricRequest( - context.Background(), publicDashboard, publicDashboardPD, 49, @@ -1319,7 +1320,6 @@ func TestBuildMetricRequest(t *testing.T) { publicDashboard := insertTestDashboard(t, dashboardStore, "testDashWithHiddenQuery", 1, 0, true, []map[string]interface{}{}, customPanels) reqDTO, err := service.buildMetricRequest( - context.Background(), publicDashboard, publicDashboardPD, 1, @@ -1611,6 +1611,158 @@ func TestSanitizeMetadataFromQueryData(t *testing.T) { }) } +func TestBuildTimeSettings(t *testing.T) { + var defaultDashboardData = simplejson.NewFromAny(map[string]interface{}{ + "time": map[string]interface{}{ + "from": "2022-09-01T00:00:00.000Z", "to": "2022-09-01T12:00:00.000Z", + }, + "timezone": "America/Argentina/Mendoza", + }) + + defaultFromMs, defaultToMs := internal.GetTimeRangeFromDashboard(t, defaultDashboardData) + + fakeTimezone, _ := time.LoadLocation("Europe/Madrid") + fakeNow := time.Date(2018, 12, 9, 20, 30, 0, 0, fakeTimezone) + + // stub time range construction to have a fixed time.Now and be able to tests relative time ranges + NewDataTimeRange = func(from, to string) legacydata.DataTimeRange { + return legacydata.DataTimeRange{ + From: from, + To: to, + Now: fakeNow, + } + } + + startOfYesterdayMadrid, endOfYesterdayMadrid := getStartAndEndOfTheDayBefore(fakeNow, "Europe/Madrid") + + // the day before fakeNow in Australia/Sydney timezone is not the same day before as in Europe/Madrid + startOfYesterdaySydney, endOfYesterdaySydney := getStartAndEndOfTheDayBefore(fakeNow, "Australia/Sydney") + startOfYesterdayUTC, endOfYesterdayUTC := getStartAndEndOfTheDayBefore(fakeNow, "UTC") + + selectionFromMs := strconv.FormatInt(time.Now().UnixMilli(), 10) + selectionToMs := strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10) + + testCases := []struct { + name string + dashboard *dashboards.Dashboard + pubdash *PublicDashboard + reqDTO PublicDashboardQueryDTO + want TimeSettings + }{ + { + name: "should return default time range with timezone with relative time range", + dashboard: &dashboards.Dashboard{Data: buildJsonDataWithTimeRange("now-1d/d", "now-1d/d", "Australia/Sydney")}, + pubdash: &PublicDashboard{TimeSelectionEnabled: false}, + reqDTO: PublicDashboardQueryDTO{}, + want: TimeSettings{ + From: strconv.FormatInt(startOfYesterdaySydney.UnixMilli(), 10), + To: strconv.FormatInt(endOfYesterdaySydney.UnixMilli(), 10), + }, + }, + { + name: "should return default time range with UTC timezone with relative time range with unknown timezone", + dashboard: &dashboards.Dashboard{Data: buildJsonDataWithTimeRange("now-1d/d", "now-1d/d", "browser")}, + pubdash: &PublicDashboard{TimeSelectionEnabled: false}, + reqDTO: PublicDashboardQueryDTO{}, + want: TimeSettings{ + From: strconv.FormatInt(startOfYesterdayUTC.UnixMilli(), 10), + To: strconv.FormatInt(endOfYesterdayUTC.UnixMilli(), 10), + }, + }, + { + name: "should return default time range with timezone with relative time range if time selection is not enabled", + dashboard: &dashboards.Dashboard{Data: buildJsonDataWithTimeRange("now-1d/d", "now-1d/d", "Australia/Sydney")}, + pubdash: &PublicDashboard{TimeSelectionEnabled: false}, + reqDTO: PublicDashboardQueryDTO{ + TimeRange: TimeRangeDTO{ + Timezone: "Europe/Madrid", + }}, + want: TimeSettings{ + From: strconv.FormatInt(startOfYesterdaySydney.UnixMilli(), 10), + To: strconv.FormatInt(endOfYesterdaySydney.UnixMilli(), 10), + }, + }, + { + name: "should return user time range with dashboard timezone with relative time range", + dashboard: &dashboards.Dashboard{Data: buildJsonDataWithTimeRange("now-1d/d", "now-1d/d", "Europe/Madrid")}, + pubdash: &PublicDashboard{TimeSelectionEnabled: false}, + reqDTO: PublicDashboardQueryDTO{}, + want: TimeSettings{ + From: strconv.FormatInt(startOfYesterdayMadrid.UnixMilli(), 10), + To: strconv.FormatInt(endOfYesterdayMadrid.UnixMilli(), 10), + }, + }, + { + name: "should return user time range with dashboard timezone with relative time range for the last hour", + dashboard: &dashboards.Dashboard{Data: buildJsonDataWithTimeRange("now-1h", "now", "Europe/Madrid")}, + pubdash: &PublicDashboard{TimeSelectionEnabled: false}, + reqDTO: PublicDashboardQueryDTO{}, + want: TimeSettings{ + From: strconv.FormatInt(fakeNow.Add(-time.Hour).UnixMilli(), 10), + To: strconv.FormatInt(fakeNow.UnixMilli(), 10), + }, + }, + { + name: "should use dashboard time if pubdash time empty", + dashboard: &dashboards.Dashboard{Data: defaultDashboardData}, + pubdash: &PublicDashboard{TimeSelectionEnabled: false}, + reqDTO: PublicDashboardQueryDTO{}, + want: TimeSettings{ + From: defaultFromMs, + To: defaultToMs, + }, + }, + { + name: "should use dashboard time when time selection is disabled", + dashboard: &dashboards.Dashboard{Data: defaultDashboardData}, + pubdash: &PublicDashboard{TimeSelectionEnabled: false}, + reqDTO: PublicDashboardQueryDTO{ + TimeRange: TimeRangeDTO{ + From: selectionFromMs, + To: selectionToMs, + }, + }, + want: TimeSettings{ + From: defaultFromMs, + To: defaultToMs, + }, + }, + { + name: "should use selected values if time selection is enabled", + dashboard: &dashboards.Dashboard{Data: defaultDashboardData}, + pubdash: &PublicDashboard{TimeSelectionEnabled: true}, + reqDTO: PublicDashboardQueryDTO{ + TimeRange: TimeRangeDTO{ + From: selectionFromMs, + To: selectionToMs, + }, + }, + want: TimeSettings{ + From: selectionFromMs, + To: selectionToMs, + }, + }, + { + name: "should use default values if time selection is enabled but the time range is empty", + dashboard: &dashboards.Dashboard{Data: defaultDashboardData}, + pubdash: &PublicDashboard{TimeSelectionEnabled: true}, + reqDTO: PublicDashboardQueryDTO{ + TimeRange: TimeRangeDTO{}, + }, + want: TimeSettings{ + From: defaultFromMs, + To: defaultToMs, + }, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, buildTimeSettings(test.dashboard, test.reqDTO, test.pubdash)) + }) + } +} + func groupQueriesByDataSource(t *testing.T, queries []*simplejson.Json) (result [][]*simplejson.Json) { t.Helper() byDataSource := make(map[string][]*simplejson.Json) @@ -1626,3 +1778,21 @@ func groupQueriesByDataSource(t *testing.T, queries []*simplejson.Json) (result return } + +func getStartAndEndOfTheDayBefore(fakeNow time.Time, timezoneName string) (time.Time, time.Time) { + timezone, _ := time.LoadLocation(timezoneName) + fakeNowWithTimezone := fakeNow.In(timezone) + yy, mm, dd := fakeNowWithTimezone.Add(-24 * time.Hour).Date() + startOfYesterdaySydney := time.Date(yy, mm, dd, 0, 0, 0, 0, timezone) + endOfYesterdaySydney := time.Date(yy, mm, dd, 23, 59, 59, 999999999, timezone) + return startOfYesterdaySydney, endOfYesterdaySydney +} + +func buildJsonDataWithTimeRange(from, to, timezone string) *simplejson.Json { + return simplejson.NewFromAny(map[string]interface{}{ + "time": map[string]interface{}{ + "from": from, "to": to, + }, + "timezone": timezone, + }) +} diff --git a/pkg/services/publicdashboards/validation/validation_test.go b/pkg/services/publicdashboards/validation/validation_test.go index e55a3e6d530..bf045ca7e47 100644 --- a/pkg/services/publicdashboards/validation/validation_test.go +++ b/pkg/services/publicdashboards/validation/validation_test.go @@ -40,7 +40,7 @@ func TestValidateQueryPublicDashboardRequest(t *testing.T) { req: PublicDashboardQueryDTO{ IntervalMs: 1000, MaxDataPoints: 1000, - TimeRange: TimeSettings{ + TimeRange: TimeRangeDTO{ From: "now-1h", To: "now", }, @@ -88,7 +88,7 @@ func TestValidateQueryPublicDashboardRequest(t *testing.T) { name: "Returns validation error when time range from is invalid", args: args{ req: PublicDashboardQueryDTO{ - TimeRange: TimeSettings{ + TimeRange: TimeRangeDTO{ From: "invalid", To: "1622560000000", }, @@ -103,7 +103,7 @@ func TestValidateQueryPublicDashboardRequest(t *testing.T) { name: "Returns validation error when time range to is invalid", args: args{ req: PublicDashboardQueryDTO{ - TimeRange: TimeSettings{ + TimeRange: TimeRangeDTO{ From: "1622560000000", To: "invalid", }, @@ -118,7 +118,7 @@ func TestValidateQueryPublicDashboardRequest(t *testing.T) { name: "Returns validation error when time range from or to is blank", args: args{ req: PublicDashboardQueryDTO{ - TimeRange: TimeSettings{ + TimeRange: TimeRangeDTO{ From: "", To: "", }, diff --git a/public/app/features/dashboard/services/PublicDashboardDataSource.ts b/public/app/features/dashboard/services/PublicDashboardDataSource.ts index 02ce4b8b6a7..62cb08c8bf6 100644 --- a/public/app/features/dashboard/services/PublicDashboardDataSource.ts +++ b/public/app/features/dashboard/services/PublicDashboardDataSource.ts @@ -113,7 +113,11 @@ export class PublicDashboardDataSource extends DataSourceApi { return Promise.resolve({ message: '', status: '' }); } + + // Try to get the browser timezone otherwise return blank + getBrowserTimezone(): string { + return window.Intl?.DateTimeFormat().resolvedOptions()?.timeZone || ''; + } }