diff --git a/conf/defaults.ini b/conf/defaults.ini index ca4561fc4e0..1a4e1e828b6 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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 diff --git a/conf/sample.ini b/conf/sample.ini index 5d50ad20207..868bc750d73 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -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 diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 14bfd8404ce..9d3d20baa10 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -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//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` diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index e8fa818a250..a52d60c7e27 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -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; exploreDefaultTimeOffset?: string; exploreHideLogsDownload?: boolean; + quickRanges?: TimeOption[]; // The namespace to use for kubernetes apiserver requests namespace: string; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index c19bae362d5..50e7ced3448 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -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; 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 diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index b3e402b0f62..2c4406f41c9 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -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"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 5ef55403d4d..3a062fedad4 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -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, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index fcab4269a3f..ff53a2ffb5b 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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() diff --git a/pkg/setting/setting_time_picker.go b/pkg/setting/setting_time_picker.go new file mode 100644 index 00000000000..bae55f00441 --- /dev/null +++ b/pkg/setting/setting_time_picker.go @@ -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 +} diff --git a/pkg/setting/setting_time_picker_test.go b/pkg/setting/setting_time_picker_test.go new file mode 100644 index 00000000000..50414ced1c5 --- /dev/null +++ b/pkg/setting/setting_time_picker_test.go @@ -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")) + }) +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index fe2ef3ec1f9..10dbc1d11e9 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -207,6 +207,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo