PublicDashboards: Support timezone on query API (#68560)

This commit is contained in:
Ezequiel Victorero 2023-06-14 17:35:01 -03:00 committed by GitHub
parent 7ea9be6832
commit fc374f93a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 253 additions and 97 deletions

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -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: "",
},

View File

@ -113,7 +113,11 @@ export class PublicDashboardDataSource extends DataSourceApi<DataQuery, DataSour
intervalMs,
maxDataPoints,
queryCachingTTL,
timeRange: { from: fromRange.valueOf().toString(), to: toRange.valueOf().toString() },
timeRange: {
from: fromRange.valueOf().toString(),
to: toRange.valueOf().toString(),
timezone: this.getBrowserTimezone(),
},
};
return getBackendSrv()
@ -155,4 +159,9 @@ export class PublicDashboardDataSource extends DataSourceApi<DataQuery, DataSour
testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve({ message: '', status: '' });
}
// Try to get the browser timezone otherwise return blank
getBrowserTimezone(): string {
return window.Intl?.DateTimeFormat().resolvedOptions()?.timeZone || '';
}
}