Dashboard: Add configurable quick ranges for the time picker (#102254)

* Dashboard: Add configurable quick ranges for the time picker

* fix test and linter errors

* update from array to TimeOption

* Switching to grafana-scenes (Part 1 - remove grafana-ui changes

* Update SceneTimePicker initialization

* betterer

* remove hallucinated argument

* Revert "Bump scenes and fix types (#105167)"

This reverts commit c6428dfc74.

* make gen-go

* reset files

* Shorten documentation to increase maintainability

* Update _index.md

* the

---------

Co-authored-by: joshhunt <josh.hunt@grafana.com>
Co-authored-by: Jacob Valdez <jacob.valdez@grafana.com>
This commit is contained in:
Chris Hodges
2025-06-12 17:51:46 +02:00
committed by GitHub
co-authored by joshhunt Jacob Valdez
parent f02ad33fd2
commit e0d27dc0d7
12 changed files with 246 additions and 1 deletions
+6
View File
@@ -312,6 +312,7 @@ type Cfg struct {
Anonymous AnonymousSettings
DateFormats DateFormats
QuickRanges QuickRanges
// User
UserInviteMaxLifetime time.Duration
@@ -1388,6 +1389,11 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
cfg.ScopesListScopesURL = scopesSection.Key("list_scopes_endpoint").MustString("")
cfg.ScopesListDashboardsURL = scopesSection.Key("list_dashboards_endpoint").MustString("")
// Time picker settings
if err := cfg.readTimePicker(); err != nil {
return err
}
// unified storage config
cfg.setUnifiedStorageConfig()
+57
View File
@@ -0,0 +1,57 @@
package setting
import (
"encoding/json"
"fmt"
)
// QuickRanges is a slice of QuickRange objects that can be directly used in frontend
type QuickRanges []QuickRange
// QuickRange represents a time range option in the time picker.
// It defines a preset time range that users can select from the time picker dropdown.
type QuickRange struct {
// Display is the user-friendly label shown in the UI for this time range
Display string `json:"display"`
// From is the start of the time range in a format like "now-6h" or an absolute time
From string `json:"from"`
// To is the end of the time range, defaults to "now" if omitted
To string `json:"to,omitempty"`
}
func (cfg *Cfg) readTimePicker() error {
timePickerSection := cfg.Raw.Section("time_picker")
quickRangesStr := timePickerSection.Key("quick_ranges").String()
if quickRangesStr == "" {
cfg.QuickRanges = []QuickRange{}
return nil
}
var quickRanges []QuickRange
err := json.Unmarshal([]byte(quickRangesStr), &quickRanges)
if err != nil {
cfg.Logger.Error("Failed to parse quick_ranges", "error", err)
return fmt.Errorf("failed to parse quick_ranges: %w", err)
}
// Validate the quick ranges and set defaults
for i, qr := range quickRanges {
if qr.Display == "" {
cfg.Logger.Error("Quick range is missing display name", "index", i)
return fmt.Errorf("quick range at index %d is missing display name", i)
}
if qr.From == "" {
cfg.Logger.Error("Quick range is missing 'from' field", "display", qr.Display)
return fmt.Errorf("quick range '%s' is missing 'from' field", qr.Display)
}
// Set default value for To field if it's empty
if qr.To == "" {
quickRanges[i].To = "now"
}
}
cfg.QuickRanges = quickRanges
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package setting
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
)
func TestReadTimePicker(t *testing.T) {
t.Run("Default values when quick_ranges not specified", func(t *testing.T) {
cfg := NewCfg()
iniContent := `
[time_picker]
`
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg.Raw = iniFile
err = cfg.readTimePicker()
require.NoError(t, err)
// Default values should be used
assert.Empty(t, cfg.QuickRanges)
})
t.Run("Parse valid quick_ranges", func(t *testing.T) {
cfg := NewCfg()
iniContent := `
[time_picker]
quick_ranges = [{"display":"Last 5 minutes","from":"now-5m","to":"now"},{"display":"Yesterday","from":"now-1d/d"},{"display":"Today so far","from":"now/d","to":"now"}]
`
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg.Raw = iniFile
err = cfg.readTimePicker()
require.NoError(t, err)
// Validate parsed values
require.Len(t, cfg.QuickRanges, 3)
// First range
assert.Equal(t, "Last 5 minutes", cfg.QuickRanges[0].Display)
assert.Equal(t, "now-5m", cfg.QuickRanges[0].From)
assert.Equal(t, "now", cfg.QuickRanges[0].To)
// Second range (defaulted to 'now')
assert.Equal(t, "Yesterday", cfg.QuickRanges[1].Display)
assert.Equal(t, "now-1d/d", cfg.QuickRanges[1].From)
assert.Equal(t, "now", cfg.QuickRanges[1].To)
// Third range
assert.Equal(t, "Today so far", cfg.QuickRanges[2].Display)
assert.Equal(t, "now/d", cfg.QuickRanges[2].From)
assert.Equal(t, "now", cfg.QuickRanges[2].To)
})
t.Run("QuickRange with missing To field gets default value", func(t *testing.T) {
cfg := NewCfg()
iniContent := `
[time_picker]
quick_ranges = [{"display":"Yesterday","from":"now-1d/d"}]
`
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg.Raw = iniFile
err = cfg.readTimePicker()
require.NoError(t, err)
// Validate the parsed value
require.Len(t, cfg.QuickRanges, 1)
assert.Equal(t, "Yesterday", cfg.QuickRanges[0].Display)
assert.Equal(t, "now-1d/d", cfg.QuickRanges[0].From)
assert.Equal(t, "now", cfg.QuickRanges[0].To)
jsonBytes, err := json.Marshal(cfg.QuickRanges)
require.NoError(t, err)
assert.Contains(t, string(jsonBytes), "\"to\":\"now\"")
})
t.Run("Invalid JSON format", func(t *testing.T) {
cfg := NewCfg()
iniContent := `
[time_picker]
quick_ranges = [{"display":"Last 5 minutes","from":"now-5m","to":"now"}, INVALID JSON]
`
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg.Raw = iniFile
err = cfg.readTimePicker()
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "failed to parse quick_ranges"))
})
t.Run("Missing display field", func(t *testing.T) {
cfg := NewCfg()
iniContent := `
[time_picker]
quick_ranges = [{"from":"now-5m","to":"now"}]
`
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg.Raw = iniFile
err = cfg.readTimePicker()
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "missing display name"))
})
t.Run("Missing from field", func(t *testing.T) {
cfg := NewCfg()
iniContent := `
[time_picker]
quick_ranges = [{"display":"Last 5 minutes","to":"now"}]
`
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg.Raw = iniFile
err = cfg.readTimePicker()
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "missing 'from' field"))
})
}