Dashboards: Allow custom quick time ranges specified in dashboard model (#93724)

* TimeRangePicker: allow to customize quick ranges per dashboard

* TimeRangePicker: show selected custom time range using its name

* rangeutil: add tests for describeTextRange + quickRanges

* Fix up tests, and add an extra case for hidden time ranges

* Don't construct an object to find options, add findRangeInOptions util

* fix type errors detected by TypeScript

---------

Co-authored-by: joshhunt <josh@trtr.co>
This commit is contained in:
Sergey Naumov 2025-02-04 17:36:28 +03:00 committed by GitHub
parent cfae9d20d2
commit eb2f8182c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 96 additions and 28 deletions

View File

@ -136,6 +136,18 @@ The grid has a negative gravity that moves panels up if there is empty space abo
"now": true, "now": true,
"hidden": false, "hidden": false,
"nowDelay": "", "nowDelay": "",
"quick_ranges": [
{
"display": "Last 6 hours"
"from": "now-6h",
"to": "now"
},
{
"display": "Last 7 days"
"from": "now-7d",
"to": "now"
}
],
"refresh_intervals": [ "refresh_intervals": [
"5s", "5s",
"10s", "10s",
@ -163,6 +175,7 @@ Usage of the fields is explained below:
| **now** | | | **now** | |
| **hidden** | whether timepicker is hidden or not | | **hidden** | whether timepicker is hidden or not |
| **nowDelay** | override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. | | **nowDelay** | override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. |
| **quick_ranges** | custom quick ranges |
| **refresh_intervals** | interval options available in the refresh picker dropdown | | **refresh_intervals** | interval options available in the refresh picker dropdown |
| **status** | | | **status** | |
| **type** | | | **type** | |

View File

@ -459,6 +459,13 @@ lineage: schemas: [{
options: _ options: _
} @cuetsy(kind="interface") @grafana(TSVeneer="type") } @cuetsy(kind="interface") @grafana(TSVeneer="type")
// Counterpart for TypeScript's TimeOption type.
#TimeOption: {
display: string
from: string
to: string
} @cuetsy(kind="interface") @grafana(TSVeneer="type")
// Time picker configuration // Time picker configuration
// It defines the default config for the time picker and the refresh picker for the specific dashboard. // It defines the default config for the time picker and the refresh picker for the specific dashboard.
#TimePickerConfig: { #TimePickerConfig: {
@ -468,6 +475,8 @@ lineage: schemas: [{
refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
time_options?: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] time_options?: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
// Quick ranges for time picker.
quick_ranges?: [...#TimeOption]
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
nowDelay?: string nowDelay?: string
} @cuetsy(kind="interface") @grafana(TSVeneer="type") } @cuetsy(kind="interface") @grafana(TSVeneer="type")

View File

@ -1,5 +1,3 @@
import { each } from 'lodash';
import { RawTimeRange, TimeRange, TimeZone, IntervalValues, RelativeTimeRange, TimeOption } from '../types/time'; import { RawTimeRange, TimeRange, TimeZone, IntervalValues, RelativeTimeRange, TimeOption } from '../types/time';
import * as dateMath from './datemath'; import * as dateMath from './datemath';
@ -17,7 +15,7 @@ const spans: { [key: string]: { display: string; section?: number } } = {
y: { display: 'year' }, y: { display: 'year' },
}; };
const rangeOptions: TimeOption[] = [ const BASE_RANGE_OPTIONS: TimeOption[] = [
{ from: 'now/d', to: 'now/d', display: 'Today' }, { from: 'now/d', to: 'now/d', display: 'Today' },
{ from: 'now/d', to: 'now', display: 'Today so far' }, { from: 'now/d', to: 'now', display: 'Today so far' },
{ from: 'now/w', to: 'now/w', display: 'This week' }, { from: 'now/w', to: 'now/w', display: 'This week' },
@ -66,7 +64,7 @@ const rangeOptions: TimeOption[] = [
{ from: 'now/fy', to: 'now/fy', display: 'This fiscal year' }, { from: 'now/fy', to: 'now/fy', display: 'This fiscal year' },
]; ];
const hiddenRangeOptions: TimeOption[] = [ const HIDDEN_RANGE_OPTIONS: TimeOption[] = [
{ from: 'now', to: 'now+1m', display: 'Next minute' }, { from: 'now', to: 'now+1m', display: 'Next minute' },
{ from: 'now', to: 'now+5m', display: 'Next 5 minutes' }, { from: 'now', to: 'now+5m', display: 'Next 5 minutes' },
{ from: 'now', to: 'now+15m', display: 'Next 15 minutes' }, { from: 'now', to: 'now+15m', display: 'Next 15 minutes' },
@ -86,13 +84,11 @@ const hiddenRangeOptions: TimeOption[] = [
{ from: 'now', to: 'now+5y', display: 'Next 5 years' }, { from: 'now', to: 'now+5y', display: 'Next 5 years' },
]; ];
const rangeIndex: Record<string, TimeOption> = {}; const STANDARD_RANGE_OPTIONS = BASE_RANGE_OPTIONS.concat(HIDDEN_RANGE_OPTIONS);
each(rangeOptions, (frame) => {
rangeIndex[frame.from + ' to ' + frame.to] = frame; function findRangeInOptions(range: RawTimeRange, options: TimeOption[]) {
}); return options.find((option) => option.from === range.from && option.to === range.to);
each(hiddenRangeOptions, (frame) => { }
rangeIndex[frame.from + ' to ' + frame.to] = frame;
});
// handles expressions like // handles expressions like
// 5m // 5m
@ -106,7 +102,7 @@ export function describeTextRange(expr: string): TimeOption {
expr = (isLast ? 'now-' : 'now') + expr; expr = (isLast ? 'now-' : 'now') + expr;
} }
let opt = rangeIndex[expr + ' to now']; let opt = findRangeInOptions({ from: expr, to: 'now' }, STANDARD_RANGE_OPTIONS);
if (opt) { if (opt) {
return opt; return opt;
} }
@ -141,17 +137,15 @@ export function describeTextRange(expr: string): TimeOption {
/** /**
* Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}. * Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}.
* *
* @example
* ```
* // Prints "2":
* console.log(add(1,1));
* ```
* @category TimeUtils * @category TimeUtils
* @param range - a time range (usually specified by the TimePicker) * @param range - a time range (usually specified by the TimePicker)
* @param timeZone - optional time zone.
* @param quickRanges - optional dashboard's custom quick ranges to pick range names from.
* @alpha * @alpha
*/ */
export function describeTimeRange(range: RawTimeRange, timeZone?: TimeZone): string { export function describeTimeRange(range: RawTimeRange, timeZone?: TimeZone, quickRanges?: TimeOption[]): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()]; const rangeOptions = quickRanges ? quickRanges.concat(STANDARD_RANGE_OPTIONS) : STANDARD_RANGE_OPTIONS;
const option = findRangeInOptions(range, rangeOptions);
if (option) { if (option) {
return option.display; return option.display;

View File

@ -73,6 +73,7 @@ export type {
VariableModel, VariableModel,
DataSourceRef, DataSourceRef,
DataTransformerConfig, DataTransformerConfig,
TimeOption,
TimePickerConfig, TimePickerConfig,
Panel, Panel,
FieldConfigSource, FieldConfigSource,

View File

@ -651,6 +651,15 @@ export interface DataTransformerConfig {
topic?: ('series' | 'annotations' | 'alertStates'); // replaced with common.DataTopic topic?: ('series' | 'annotations' | 'alertStates'); // replaced with common.DataTopic
} }
/**
* Counterpart for TypeScript's TimeOption type.
*/
export interface TimeOption {
display: string;
from: string;
to: string;
}
/** /**
* Time picker configuration * Time picker configuration
* It defines the default config for the time picker and the refresh picker for the specific dashboard. * It defines the default config for the time picker and the refresh picker for the specific dashboard.
@ -664,6 +673,10 @@ export interface TimePickerConfig {
* Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. * Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
*/ */
nowDelay?: string; nowDelay?: string;
/**
* Quick ranges for time picker.
*/
quick_ranges?: Array<TimeOption>;
/** /**
* Interval options available in the refresh picker dropdown. * Interval options available in the refresh picker dropdown.
*/ */
@ -676,6 +689,7 @@ export interface TimePickerConfig {
export const defaultTimePickerConfig: Partial<TimePickerConfig> = { export const defaultTimePickerConfig: Partial<TimePickerConfig> = {
hidden: false, hidden: false,
quick_ranges: [],
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
}; };

View File

@ -62,6 +62,8 @@ export interface DataTransformerConfig<TOptions = any> extends raw.DataTransform
topic?: DataTopic; topic?: DataTopic;
} }
export interface TimeOption extends raw.TimeOption {}
export interface TimePickerConfig extends raw.TimePickerConfig {} export interface TimePickerConfig extends raw.TimePickerConfig {}
export const defaultDashboard = raw.defaultDashboard as Dashboard; export const defaultDashboard = raw.defaultDashboard as Dashboard;

View File

@ -9,6 +9,7 @@ import {
GrafanaTheme2, GrafanaTheme2,
dateTimeFormat, dateTimeFormat,
timeZoneFormatUserFriendly, timeZoneFormatUserFriendly,
TimeOption,
TimeRange, TimeRange,
TimeZone, TimeZone,
dateMath, dateMath,
@ -55,6 +56,7 @@ export interface TimeRangePickerProps {
onZoom: () => void; onZoom: () => void;
onError?: (error?: string) => void; onError?: (error?: string) => void;
history?: TimeRange[]; history?: TimeRange[];
quickRanges?: TimeOption[];
hideQuickRanges?: boolean; hideQuickRanges?: boolean;
widthOverride?: number; widthOverride?: number;
isOnCanvas?: boolean; isOnCanvas?: boolean;
@ -81,6 +83,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
history, history,
onChangeTimeZone, onChangeTimeZone,
onChangeFiscalYearStartMonth, onChangeFiscalYearStartMonth,
quickRanges,
hideQuickRanges, hideQuickRanges,
widthOverride, widthOverride,
isOnCanvas, isOnCanvas,
@ -141,7 +144,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
const isFromAfterTo = value?.to?.isBefore(value.from); const isFromAfterTo = value?.to?.isBefore(value.from);
const timePickerIcon = isFromAfterTo ? 'exclamation-triangle' : 'clock-nine'; const timePickerIcon = isFromAfterTo ? 'exclamation-triangle' : 'clock-nine';
const currentTimeRange = formattedRange(value, timeZone); const currentTimeRange = formattedRange(value, timeZone, quickRanges);
return ( return (
<ButtonGroup className={styles.container}> <ButtonGroup className={styles.container}>
@ -187,7 +190,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
fiscalYearStartMonth={fiscalYearStartMonth} fiscalYearStartMonth={fiscalYearStartMonth}
value={value} value={value}
onChange={onChange} onChange={onChange}
quickOptions={quickOptions} quickOptions={quickRanges || quickOptions}
history={history} history={history}
showHistory showHistory
widthOverride={widthOverride} widthOverride={widthOverride}
@ -255,9 +258,9 @@ export const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRang
); );
}; };
type LabelProps = Pick<TimeRangePickerProps, 'hideText' | 'value' | 'timeZone'>; type LabelProps = Pick<TimeRangePickerProps, 'hideText' | 'value' | 'timeZone' | 'quickRanges'>;
export const TimePickerButtonLabel = memo<LabelProps>(({ hideText, value, timeZone }) => { export const TimePickerButtonLabel = memo<LabelProps>(({ hideText, value, timeZone, quickRanges }) => {
const styles = useStyles2(getLabelStyles); const styles = useStyles2(getLabelStyles);
if (hideText) { if (hideText) {
@ -266,7 +269,7 @@ export const TimePickerButtonLabel = memo<LabelProps>(({ hideText, value, timeZo
return ( return (
<span className={styles.container} aria-live="polite" aria-atomic="true"> <span className={styles.container} aria-live="polite" aria-atomic="true">
<span>{formattedRange(value, timeZone)}</span> <span>{formattedRange(value, timeZone, quickRanges)}</span>
<span className={styles.utc}>{rangeUtil.describeTimeRangeAbbreviation(value, timeZone)}</span> <span className={styles.utc}>{rangeUtil.describeTimeRangeAbbreviation(value, timeZone)}</span>
</span> </span>
); );
@ -274,12 +277,12 @@ export const TimePickerButtonLabel = memo<LabelProps>(({ hideText, value, timeZo
TimePickerButtonLabel.displayName = 'TimePickerButtonLabel'; TimePickerButtonLabel.displayName = 'TimePickerButtonLabel';
const formattedRange = (value: TimeRange, timeZone?: TimeZone) => { const formattedRange = (value: TimeRange, timeZone?: TimeZone, quickRanges?: TimeOption[]) => {
const adjustedTimeRange = { const adjustedTimeRange = {
to: dateMath.isMathString(value.raw.to) ? value.raw.to : value.to, to: dateMath.isMathString(value.raw.to) ? value.raw.to : value.to,
from: dateMath.isMathString(value.raw.from) ? value.raw.from : value.from, from: dateMath.isMathString(value.raw.from) ? value.raw.from : value.from,
}; };
return rangeUtil.describeTimeRange(adjustedTimeRange, timeZone); return rangeUtil.describeTimeRange(adjustedTimeRange, timeZone, quickRanges);
}; };
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {

View File

@ -29,6 +29,18 @@ const (
DashboardCursorSyncTooltip DashboardCursorSync = 2 DashboardCursorSyncTooltip DashboardCursorSync = 2
) )
// Counterpart for TypeScript's TimeOption type.
type TimeOption struct {
Display string `json:"display"`
From string `json:"from"`
To string `json:"to"`
}
// NewTimeOption creates a new TimeOption object.
func NewTimeOption() *TimeOption {
return &TimeOption{}
}
// Time picker configuration // Time picker configuration
// It defines the default config for the time picker and the refresh picker for the specific dashboard. // It defines the default config for the time picker and the refresh picker for the specific dashboard.
type TimePickerConfig struct { type TimePickerConfig struct {
@ -38,6 +50,8 @@ type TimePickerConfig struct {
RefreshIntervals []string `json:"refresh_intervals,omitempty"` RefreshIntervals []string `json:"refresh_intervals,omitempty"`
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
TimeOptions []string `json:"time_options,omitempty"` TimeOptions []string `json:"time_options,omitempty"`
// Quick ranges for time picker.
QuickRanges []TimeOption `json:"quick_ranges,omitempty"`
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
NowDelay *string `json:"nowDelay,omitempty"` NowDelay *string `json:"nowDelay,omitempty"`
} }

View File

@ -1,4 +1,4 @@
import { rangeUtil, dateTime } from '@grafana/data'; import { rangeUtil, dateTime, TimeOption } from '@grafana/data';
describe('rangeUtil', () => { describe('rangeUtil', () => {
describe('Can get range text described', () => { describe('Can get range text described', () => {
@ -57,6 +57,11 @@ describe('rangeUtil', () => {
expect(text).toBe('now/d+6h to now'); expect(text).toBe('now/d+6h to now');
}); });
it('matches hidden time ranges', () => {
const text = rangeUtil.describeTimeRange({ from: 'now', to: 'now+30m' });
expect(text).toBe('Next 30 minutes');
});
it('Date range with absolute to now', () => { it('Date range with absolute to now', () => {
const text = rangeUtil.describeTimeRange({ const text = rangeUtil.describeTimeRange({
from: dateTime([2014, 10, 10, 2, 3, 4]), from: dateTime([2014, 10, 10, 2, 3, 4]),
@ -103,5 +108,17 @@ describe('rangeUtil', () => {
const text = rangeUtil.describeTimeRange({ from: 'now-6h', to: 'now+1h' }); const text = rangeUtil.describeTimeRange({ from: 'now-6h', to: 'now+1h' });
expect(text).toBe('now-6h to now+1h'); expect(text).toBe('now-6h to now+1h');
}); });
it('Date range that is in custom quick ranges', () => {
const opt: TimeOption = { from: 'now-4w/w', to: 'now-1w/w', display: 'Previous 4 weeks' };
const text = rangeUtil.describeTimeRange({ from: opt.from, to: opt.to }, undefined, [opt]);
expect(text).toBe('Previous 4 weeks');
});
it('Date range description from custom quick ranges has higher priority', () => {
const opt: TimeOption = { from: 'now/d', to: 'now/d', display: 'This day' };
const text = rangeUtil.describeTimeRange({ from: opt.from, to: opt.to }, undefined, [opt]);
expect(text).toBe('This day'); // overrides 'Today'
});
}); });
}); });

View File

@ -93,7 +93,7 @@ export class DashNavTimeControls extends Component<Props> {
render() { render() {
const { dashboard, isOnCanvas } = this.props; const { dashboard, isOnCanvas } = this.props;
const { refresh_intervals } = dashboard.timepicker; const { quick_ranges, refresh_intervals } = dashboard.timepicker;
const intervals = getTimeSrv().getValidIntervals(refresh_intervals || defaultIntervals); const intervals = getTimeSrv().getValidIntervals(refresh_intervals || defaultIntervals);
const timePickerValue = getTimeSrv().timeRange(); const timePickerValue = getTimeSrv().timeRange();
@ -122,6 +122,7 @@ export class DashNavTimeControls extends Component<Props> {
isOnCanvas={isOnCanvas} isOnCanvas={isOnCanvas}
onToolbarTimePickerClick={this.props.onToolbarTimePickerClick} onToolbarTimePickerClick={this.props.onToolbarTimePickerClick}
weekStart={weekStart} weekStart={weekStart}
quickRanges={quick_ranges}
/> />
<RefreshPicker <RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval} onIntervalChanged={this.onChangeRefreshInterval}