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:
co-authored by
joshhunt
Jacob Valdez
parent
f02ad33fd2
commit
e0d27dc0d7
@@ -1987,6 +1987,11 @@ provider = static
|
||||
# Default system date format used in time range picker and other places where full time is displayed
|
||||
full_date = YYYY-MM-DD HH:mm:ss
|
||||
|
||||
[time_picker]
|
||||
# Custom quick ranges for the time picker. Each quick range has a display name, a from value, and a to value.
|
||||
# Format: [{"from":"now-5m","to":"now","display":"Last 5 minutes"},{"from":"now-15m","to":"now","display":"Last 15 minutes"}]
|
||||
quick_ranges =
|
||||
|
||||
# Used by graph and other places where we only show small intervals
|
||||
interval_second = HH:mm:ss
|
||||
interval_minute = HH:mm
|
||||
|
||||
@@ -1924,6 +1924,11 @@ default_datasource_uid =
|
||||
# Default timezone for user preferences. Options are 'browser' for the browser local timezone or a timezone name from IANA Time Zone database, e.g. 'UTC' or 'Europe/Amsterdam' etc.
|
||||
;default_timezone = browser
|
||||
|
||||
[time_picker]
|
||||
# Custom quick ranges for the time picker. Each quick range has a display name, a from value, and a to value.
|
||||
# Format: [{"from":"now-5m","to":"now","display":"Last 5 minutes"},{"from":"now-15m","to":"now","display":"Last 15 minutes"}]
|
||||
;quick_ranges =
|
||||
|
||||
[expressions]
|
||||
# Enable or disable the expressions functionality.
|
||||
;enabled = true
|
||||
|
||||
@@ -2814,6 +2814,40 @@ Used as the default time zone for user preferences. Can be either `browser` for
|
||||
|
||||
Set the default start of the week, valid values are: `saturday`, `sunday`, `monday` or `browser` to use the browser locale to define the first day of the week. Default is `browser`.
|
||||
|
||||
### `[time_picker]`
|
||||
|
||||
This section controls system-wide defaults for the time picker, such as the default quick ranges.
|
||||
|
||||
#### `quick_ranges`
|
||||
|
||||
Set the default set of quick relative offset time ranges that show up in the right column of the time picker. Each configuration entry must have a `from`, `to`, and `display` field. Any configuration for this field must be in valid JSON format made up of a list of quick range configurations.
|
||||
|
||||
The `from` and `to` fields should be valid relative time ranges. For more information the relative time formats, refer to [Time units and relative ranges.](/docs/grafana/<GRAFANA_VERSION>/dashboards/use-dashboards/#time-units-and-relative-ranges). The `from` field is required, but omitting `to` will result in the `from` value being used in both fields.
|
||||
|
||||
If no configuration is provided, the default time ranges will be used.
|
||||
|
||||
For example:
|
||||
|
||||
```ini
|
||||
[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",
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `[expressions]`
|
||||
|
||||
#### `enabled`
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NavLinkDTO } from './navModel';
|
||||
import { OrgRole } from './orgs';
|
||||
import { PanelPluginMeta } from './panel';
|
||||
import { GrafanaTheme } from './theme';
|
||||
import { TimeOption } from './time';
|
||||
|
||||
/**
|
||||
* Describes the build information that will be available via the Grafana configuration.
|
||||
@@ -240,6 +241,7 @@ export interface GrafanaConfig {
|
||||
reportingStaticContext?: Record<string, string>;
|
||||
exploreDefaultTimeOffset?: string;
|
||||
exploreHideLogsDownload?: boolean;
|
||||
quickRanges?: TimeOption[];
|
||||
|
||||
// The namespace to use for kubernetes apiserver requests
|
||||
namespace: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PluginLoadingStrategy,
|
||||
PluginDependencies,
|
||||
PluginExtensions,
|
||||
TimeOption,
|
||||
} from '@grafana/data';
|
||||
|
||||
export interface AzureSettings {
|
||||
@@ -203,6 +204,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
reportingStaticContext?: Record<string, string>;
|
||||
exploreDefaultTimeOffset = '1h';
|
||||
exploreHideLogsDownload: boolean | undefined;
|
||||
quickRanges?: TimeOption[];
|
||||
|
||||
/**
|
||||
* Language used in Grafana's UI. This is after the user's preference (or deteceted locale) is resolved to one of
|
||||
|
||||
@@ -274,7 +274,8 @@ type FrontendSettingsDTO struct {
|
||||
CloudMigrationIsTarget bool `json:"cloudMigrationIsTarget"`
|
||||
CloudMigrationPollIntervalMs int `json:"cloudMigrationPollIntervalMs"`
|
||||
|
||||
DateFormats setting.DateFormats `json:"dateFormats,omitempty"`
|
||||
DateFormats setting.DateFormats `json:"dateFormats,omitempty"`
|
||||
QuickRanges []setting.QuickRange `json:"quickRanges,omitempty"`
|
||||
|
||||
LoginError string `json:"loginError,omitempty"`
|
||||
|
||||
|
||||
@@ -247,6 +247,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
TrustedTypesDefaultPolicyEnabled: trustedTypesDefaultPolicyEnabled,
|
||||
CSPReportOnlyEnabled: hs.Cfg.CSPReportOnlyEnabled,
|
||||
DateFormats: hs.Cfg.DateFormats,
|
||||
QuickRanges: hs.Cfg.QuickRanges,
|
||||
SecureSocksDSProxyEnabled: hs.Cfg.SecureSocksDSProxy.Enabled && hs.Cfg.SecureSocksDSProxy.ShowUI,
|
||||
EnableFrontendSandboxForPlugins: hs.Cfg.EnableFrontendSandboxForPlugins,
|
||||
PublicDashboardAccessToken: c.PublicDashboardAccessToken,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
@@ -207,6 +207,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
||||
controls: new DashboardControls({
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: dashboard.timeSettings.quickRanges,
|
||||
defaultQuickRanges: config.quickRanges,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: dashboard.timeSettings.autoRefresh,
|
||||
|
||||
@@ -353,6 +353,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
|
||||
controls: new DashboardControls({
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: oldModel.timepicker.quick_ranges,
|
||||
defaultQuickRanges: config.quickRanges,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: oldModel.refresh,
|
||||
|
||||
Reference in New Issue
Block a user