PanelTimeSettings: Support panel time range settings changes from dashboard in view mode (#113027)

* Something is working

* Progress

* Update

* Update

* Update

* Some new unit tests

* Fix

* time shift fix

* Update

* Always show hidden toggle

* Update
This commit is contained in:
Torkel Ödegaard
2025-10-29 09:06:23 +01:00
committed by GitHub
parent 25dd7e927f
commit 5a031b370f
32 changed files with 445 additions and 254 deletions
@@ -1233,6 +1233,10 @@ export interface FeatureToggles {
*/
onlyStoreActionSets?: boolean;
/**
* Enables a new panel time settings drawer
*/
panelTimeSettings?: boolean;
/**
* Enable template dashboards
*/
dashboardTemplates?: boolean;
@@ -39,7 +39,13 @@ export const TitleItem = forwardRef<TitleItemElement, TitleItemProps>(
);
} else if (onClick) {
return (
<Button ref={ref} className={cx(styles.item, className)} variant="secondary" fill="text" onClick={onClick}>
<Button
ref={ref}
className={cx(styles.buttonItem, className)}
variant="secondary"
fill="text"
onClick={onClick}
>
{children}
</Button>
);
@@ -59,7 +65,6 @@ const getStyles = (theme: GrafanaTheme2) => {
const item = css({
color: `${theme.colors.text.secondary}`,
label: 'panel-header-item',
cursor: 'auto',
border: 'none',
borderRadius: `${theme.shape.radius.default}`,
padding: `${theme.spacing(0, 1)}`,
@@ -84,5 +89,6 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
item,
linkItem: cx(item, css({ cursor: 'pointer' })),
buttonItem: cx(item, css({ cursor: 'pointer' })),
};
};
@@ -4,4 +4,3 @@ export * from './legend';
export { addTooltipOptions } from './tooltip';
export * from './text';
export * from './stacking';
export { addTimeCompareOption } from './timeCompare';
@@ -1,19 +0,0 @@
import { PanelOptionsEditorBuilder } from '@grafana/data';
import { TimeCompareOptions } from '@grafana/schema';
/**
* Adds a generic time comparison option to the panel options editor.
* Can be used by any panel that supports time comparison.
*/
export function addTimeCompareOption<T extends { timeCompare?: TimeCompareOptions }>(
builder: PanelOptionsEditorBuilder<T>,
defaultValue = false
) {
builder.addBooleanSwitch({
path: 'timeCompare',
name: 'Show comparison selector',
category: ['Time Comparison'],
description: 'Enables the comparison selector, so you can compare data between two time ranges',
defaultValue,
});
}
+11
View File
@@ -2133,6 +2133,17 @@ var (
Owner: identityAccessTeam,
Expression: "true",
},
{
Name: "panelTimeSettings",
Description: "Enables a new panel time settings drawer",
FrontendOnly: false,
Stage: FeatureStageExperimental,
Owner: grafanaDashboardsSquad,
RequiresRestart: false,
AllowSelfServe: false,
HideFromDocs: false,
HideFromAdminPage: false,
},
{
Name: "dashboardTemplates",
Description: "Enable template dashboards",
+1
View File
@@ -274,4 +274,5 @@ newGauge,experimental,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
274 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
275 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
276 onlyStoreActionSets GA @grafana/identity-access-team false false false
277 panelTimeSettings experimental @grafana/dashboards-squad false false false
278 dashboardTemplates experimental @grafana/sharing-squad false false false
+4
View File
@@ -1106,6 +1106,10 @@ const (
// When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission
FlagOnlyStoreActionSets = "onlyStoreActionSets"
// FlagPanelTimeSettings
// Enables a new panel time settings drawer
FlagPanelTimeSettings = "panelTimeSettings"
// FlagDashboardTemplates
// Enable template dashboards
FlagDashboardTemplates = "dashboardTemplates"
+12
View File
@@ -2886,6 +2886,18 @@
"expression": "true"
}
},
{
"metadata": {
"name": "panelTimeSettings",
"resourceVersion": "1761555646368",
"creationTimestamp": "2025-10-27T09:00:46Z"
},
"spec": {
"description": "Enables a new panel time settings drawer",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad"
}
},
{
"metadata": {
"name": "panelTitleSearch",
@@ -24,7 +24,7 @@ import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plug
import { DashboardDataDTO } from 'app/types/dashboard';
import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer';
import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange';
import { PanelTimeRange, PanelTimeRangeState } from '../../scene/panel-timerange/PanelTimeRange';
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../../utils/utils';
import { buildPanelEditScene } from '../PanelEditor';
@@ -34,7 +34,7 @@ import { useQueryLibraryContext } from '../../../explore/QueryLibrary/QueryLibra
import { ExpressionDatasourceUID } from '../../../expressions/types';
import { getDatasourceSrv } from '../../../plugins/datasource_srv';
import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer';
import { PanelTimeRange } from '../../scene/PanelTimeRange';
import { PanelTimeRange } from '../../scene/panel-timerange/PanelTimeRange';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
import { getUpdatedHoverHeader } from '../getPanelFrameOptions';
@@ -14,8 +14,8 @@ import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/
import { dashboardEditActions } from '../edit-pane/shared';
import { VizPanelLinks } from '../scene/PanelLinks';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { useEditPaneInputAutoFocus } from '../scene/layouts-shared/utils';
import { PanelTimeRange } from '../scene/panel-timerange/PanelTimeRange';
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
@@ -24,7 +24,6 @@ import { DashboardControls } from '../scene/DashboardControls';
import { DashboardScene, PERSISTED_PROPS } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks } from '../scene/PanelLinks';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
import { AutoGridLayoutManager } from '../scene/layout-auto-grid/AutoGridLayoutManager';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
@@ -32,6 +31,7 @@ import { RowItem } from '../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
import { PanelTimeRange } from '../scene/panel-timerange/PanelTimeRange';
import { isSceneVariableInstance } from '../settings/variables/utils';
import { DashboardChangeInfo } from './shared';
@@ -1,79 +0,0 @@
import { reportInteraction } from '@grafana/runtime';
import { SceneTimeRangeCompare, SceneComponentProps, VizPanel, sceneGraph } from '@grafana/scenes';
import { TimeCompareOptions } from '@grafana/schema';
function hasTimeCompare(options: unknown): options is TimeCompareOptions {
return options != null && typeof options === 'object' && 'timeCompare' in options;
}
export class CustomTimeRangeCompare extends SceneTimeRangeCompare {
private readonly parentOnCompareWithChanged: (compareWith: string) => void;
constructor(state: Partial<SceneTimeRangeCompare['state']> = {}) {
super({
...state,
compareWith: undefined,
compareOptions: [],
hideCheckbox: true,
});
this.parentOnCompareWithChanged = this.onCompareWithChanged.bind(this);
this.onCompareWithChanged = (compareWith: string) => {
const vizPanel = sceneGraph.getAncestor(this, VizPanel);
reportInteraction('panel_time_comparison', {
viz_type: vizPanel?.getPlugin()?.meta.id || 'unknown',
select_type: 'option_selected',
option_type: compareWith,
});
this.parentOnCompareWithChanged(compareWith);
};
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
// Subscribe to parent panel's options changes
const vizPanel = sceneGraph.getAncestor(this, VizPanel);
this._subs.add(
vizPanel.subscribeToState((newState, prevState) => {
const newTimeCompareEnabled = hasTimeCompare(newState.options) && newState.options.timeCompare;
const prevTimeCompareEnabled = hasTimeCompare(prevState.options) && prevState.options.timeCompare;
// Only act when transitioning from enabled to disabled
if (prevTimeCompareEnabled && !newTimeCompareEnabled) {
this._handleDisable();
}
})
);
}
private _handleDisable() {
// Only clear state if there's actually a comparison active
if (this.state.compareWith) {
this.setState({
compareWith: undefined,
});
}
}
static Component = function CustomTimeRangeCompareRenderer({ model }: SceneComponentProps<SceneTimeRangeCompare>) {
const vizPanel = sceneGraph.getAncestor(model, VizPanel);
const { options } = vizPanel.useState();
const isTimeCompareEnabled = hasTimeCompare(options) && options.timeCompare;
if (!isTimeCompareEnabled) {
return <></>;
}
return (
<div className="show-on-hover">
<SceneTimeRangeCompare.Component model={model} />
</div>
);
};
}
@@ -6,7 +6,6 @@ import { activateFullSceneTree } from '../utils/test-utils';
import { DashboardLevelTimeMacro } from './DashboardLevelTimeMacro';
import { DashboardScene } from './DashboardScene';
import { PanelTimeRange } from './PanelTimeRange';
import { AutoGridItem } from './layout-auto-grid/AutoGridItem';
import { AutoGridLayout } from './layout-auto-grid/AutoGridLayout';
import {
@@ -14,6 +13,7 @@ import {
getAutoRowsTemplate,
getTemplateColumnsTemplate,
} from './layout-auto-grid/AutoGridLayoutManager';
import { PanelTimeRange } from './panel-timerange/PanelTimeRange';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
@@ -43,10 +43,10 @@ import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../u
import { DashboardControls } from './DashboardControls';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
import { PanelTimeRange } from './PanelTimeRange';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { RowActions } from './layout-default/row-actions/RowActions';
import { PanelTimeRange } from './panel-timerange/PanelTimeRange';
jest.mock('../settings/version-history/HistorySrv');
jest.mock('../serialization/transformSaveModelToScene');
@@ -22,9 +22,9 @@ import { getPanelIdForVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
import { VizPanelLinks } from './PanelLinks';
import { PanelTimeRange } from './PanelTimeRange';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { PanelTimeRange } from './panel-timerange/PanelTimeRange';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
@@ -13,8 +13,8 @@ import { getPanelIdForVizPanel } from '../utils/utils';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { panelLinksBehavior } from './PanelMenuBehavior';
import { PanelNotices } from './PanelNotices';
import { PanelTimeRange } from './PanelTimeRange';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { PanelTimeRange } from './panel-timerange/PanelTimeRange';
export interface LibraryPanelBehaviorState extends SceneObjectState {
uid: string;
@@ -43,6 +43,7 @@ import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibra
import { DashboardScene } from './DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { UnlinkLibraryPanelModal } from './UnlinkLibraryPanelModal';
import { PanelTimeRangeDrawer } from './panel-timerange/PanelTimeRangeDrawer';
let getPluginExtensions: GetPluginExtensions;
@@ -280,6 +281,17 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
items.push(getInspectMenuItem(plugin, panel, dashboard));
if (config.featureToggles.panelTimeSettings) {
items.push({
text: t('panel.header-menu.time-settings', 'Time settings'),
iconClassName: 'clock-nine',
onClick: (e) => {
e.preventDefault();
dashboard.showModal(new PanelTimeRangeDrawer({ panelRef: panel.getRef() }));
},
});
}
setupGetPluginExtensions();
const { extensions } = getPluginExtensions({
@@ -10,7 +10,7 @@ import {
TestVariable,
} from '@grafana/scenes';
import { activateFullSceneTree } from '../utils/test-utils';
import { activateFullSceneTree } from '../../utils/test-utils';
import { PanelTimeRange } from './PanelTimeRange';
@@ -43,7 +43,7 @@ describe('PanelTimeRange', () => {
expect(panelTime.state.value.from.toISOString()).toBe('2019-02-11T11:00:00.000Z');
expect(panelTime.state.value.to.toISOString()).toBe('2019-02-11T17:00:00.000Z');
expect(panelTime.state.timeInfo).toBe(' timeshift -2h');
expect(panelTime.state.timeInfo).toBe('Timeshift -2h');
});
it('should apply both relative time and time shift', () => {
@@ -52,7 +52,23 @@ describe('PanelTimeRange', () => {
buildAndActivateSceneFor(panelTime);
expect(panelTime.state.value.from.toISOString()).toBe('2019-02-11T15:00:00.000Z');
expect(panelTime.state.timeInfo).toBe('Last 2 hours timeshift -2h');
expect(panelTime.state.timeInfo).toBe('Last 2 hours + timeshift -2h');
});
it('should add time comparison to timeInfo', () => {
const panelTime = new PanelTimeRange({ compareWith: '1d' });
buildAndActivateSceneFor(panelTime);
expect(panelTime.state.timeInfo).toBe('Compared to day before');
});
it('should add time override and time comparison to timeInfo', () => {
const panelTime = new PanelTimeRange({ timeFrom: '1h', compareWith: '1d' });
buildAndActivateSceneFor(panelTime);
expect(panelTime.state.timeInfo).toBe('Last 1 hour + compared to day before');
});
it('should update timeInfo when timeShift and timeFrom are variable expressions', async () => {
@@ -75,14 +91,14 @@ describe('PanelTimeRange', () => {
});
activateFullSceneTree(scene);
expect(panelTime.state.timeInfo).toBe('Last 10 seconds timeshift -20s');
expect(panelTime.state.timeInfo).toBe('Last 10 seconds + timeshift -20s');
customTimeFrom.setState({ value: '15s' });
customTimeShift.setState({ value: '25s' });
panelTime.forceRender();
expect(panelTime.state.timeInfo).toBe('Last 15 seconds timeshift -25s');
expect(panelTime.state.timeInfo).toBe('Last 15 seconds + timeshift -25s');
});
it('should update panelTimeRange from/to based on scene timeRange on activate', () => {
@@ -1,22 +1,35 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import { dateMath, getDefaultTimeRange, GrafanaTheme2, rangeUtil, TimeRange } from '@grafana/data';
import { DataQueryRequest, dateMath, getDefaultTimeRange, GrafanaTheme2, rangeUtil, TimeRange } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
ExtraQueryDescriptor,
SceneComponentProps,
SceneDataQuery,
sceneGraph,
SceneTimeRangeLike,
SceneTimeRangeState,
SceneTimeRangeTransformerBase,
VariableDependencyConfig,
VizPanel,
} from '@grafana/scenes';
import { Icon, PanelChrome, TimePickerTooltip, Tooltip, useStyles2 } from '@grafana/ui';
import { Icon, PanelChrome, Stack, TimePickerTooltip, Tooltip, useStyles2 } from '@grafana/ui';
import { TimeOverrideResult } from 'app/features/dashboard/utils/panel';
import { getDashboardSceneFor } from '../../utils/utils';
import { DEFAULT_COMPARE_OPTIONS, PanelTimeRangeDrawer, PanelTimeRangeZoomBehavior } from './PanelTimeRangeDrawer';
import { getCompareTimeRange, timeShiftAlignmentProcessor } from './utils';
export interface PanelTimeRangeState extends SceneTimeRangeState {
enabled?: boolean;
timeFrom?: string;
zoomBehavior?: PanelTimeRangeZoomBehavior;
timeShift?: string;
hideTimeOverride?: boolean;
timeInfo?: string;
compareWith?: string;
}
export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRangeState> implements SceneTimeRangeLike {
@@ -61,13 +74,48 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
to: timeRange.raw.to.toString(),
});
}
protected ancestorTimeRangeChanged(timeRange: SceneTimeRangeState): void {
if (this.state.timeFrom && this.state.zoomBehavior === 'dashboard') {
return;
}
const overrideResult = this.getTimeOverride(timeRange.value);
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });
}
// Get a time shifted request to compare with the primary request.
public getExtraQueries(request: DataQueryRequest): ExtraQueryDescriptor[] {
const extraQueries: ExtraQueryDescriptor[] = [];
const compareRange = getCompareTimeRange(request.range, this.state.compareWith);
if (!compareRange) {
return extraQueries;
}
const targets = request.targets.filter((query: SceneDataQuery) => query.timeRangeCompare !== false);
if (targets.length) {
extraQueries.push({
req: {
...request,
targets,
range: compareRange,
},
processor: timeShiftAlignmentProcessor,
});
}
return extraQueries;
}
// The query runner should rerun the comparison query if the compareWith value has changed and there are queries that haven't opted out of TWC
public shouldRerun(prev: PanelTimeRangeState, next: PanelTimeRangeState, queries: SceneDataQuery[]): boolean {
return (
prev.compareWith !== next.compareWith && queries.find((query) => query.timeRangeCompare !== false) !== undefined
);
}
private getTimeOverride(parentTimeRange: TimeRange): TimeOverrideResult {
const { timeFrom, timeShift } = this.state;
const { timeFrom, timeShift, compareWith } = this.state;
const infoBlocks = [];
const newTimeData = { timeInfo: '', timeRange: parentTimeRange };
if (timeFrom) {
@@ -82,12 +130,12 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
// Only evaluate if the timeFrom if parent time is relative
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
const timeZone = this.getTimeZone();
newTimeData.timeInfo = timeFromInfo.display;
newTimeData.timeRange = {
from: dateMath.parse(timeFromInfo.from, undefined, timeZone)!,
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
};
infoBlocks.push(timeFromInfo.display);
}
}
@@ -101,7 +149,8 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
}
const timeShift = '-' + timeShiftInterpolated;
newTimeData.timeInfo += ' timeshift ' + timeShift;
infoBlocks.push('timeshift ' + timeShift);
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
@@ -113,8 +162,23 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
newTimeData.timeRange = { from, to, raw: { from, to } };
}
if (compareWith) {
const option = DEFAULT_COMPARE_OPTIONS.find((x) => x.value === compareWith);
const text = option ? `compared to ${option.label.toLowerCase()}` : '';
infoBlocks.push(text);
}
newTimeData.timeInfo = capitalize(infoBlocks.join(' + '));
return newTimeData;
}
public onOpenSettings = () => {
const panel = this.parent;
const dashboard = getDashboardSceneFor(this);
if (panel instanceof VizPanel) {
dashboard.showModal(new PanelTimeRangeDrawer({ panelRef: panel.getRef() }));
}
};
}
function PanelTimeRangeRenderer({ model }: SceneComponentProps<PanelTimeRange>) {
@@ -125,10 +189,15 @@ function PanelTimeRangeRenderer({ model }: SceneComponentProps<PanelTimeRange>)
return null;
}
const onClick = config.featureToggles.panelTimeSettings ? model.onOpenSettings : undefined;
return (
<Tooltip content={<TimePickerTooltip timeRange={model.state.value} timeZone={model.getTimeZone()} />}>
<PanelChrome.TitleItem className={styles.timeshift}>
<Icon name="clock-nine" size="sm" /> {timeInfo}
<PanelChrome.TitleItem className={styles.timeshift} onClick={onClick}>
<Stack gap={1} alignItems={'center'}>
<Icon name="clock-nine" size="sm" />
<div>{timeInfo}</div>
</Stack>
</PanelChrome.TitleItem>
</Tooltip>
);
@@ -0,0 +1,201 @@
import { FeatureState } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Box, Button, Combobox, Drawer, FeatureBadge, Field, Label, Stack, Switch } from '@grafana/ui';
import { getQuickOptions } from '../../../../../../packages/grafana-ui/src/components/DateTimePickers/options';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
import { PanelTimeRange } from './PanelTimeRange';
export const DEFAULT_COMPARE_OPTIONS = [
{ label: 'Disabled', value: '' },
{ label: 'Day before', value: '1d' },
{ label: 'Week before', value: '1w' },
{ label: 'Month before', value: '1M' },
];
export type PanelTimeRangeZoomBehavior = 'panel_and_dashboard' | 'dashboard' | 'panel';
export interface PanelTimeRangeDrawerState extends SceneObjectState {
panelRef: SceneObjectRef<VizPanel>;
timeFrom?: string;
timeShift?: string;
zoomBehavior?: PanelTimeRangeZoomBehavior;
hideTimeOverride?: boolean;
compareWith?: string;
timeFromLocked?: boolean;
}
export class PanelTimeRangeDrawer extends SceneObjectBase<PanelTimeRangeDrawerState> {
public constructor(state: PanelTimeRangeDrawerState) {
super({
...state,
});
const panel = this.state.panelRef.resolve();
const timeRange = panel.state.$timeRange;
if (timeRange instanceof PanelTimeRange) {
this.setState({
timeFrom: timeRange.state.timeFrom,
timeShift: timeRange.state.timeShift,
hideTimeOverride: timeRange.state.hideTimeOverride,
compareWith: timeRange.state.compareWith,
});
}
}
public onClose = () => {
getDashboardSceneFor(this).closeModal();
};
public onApply = () => {
const panel = this.state.panelRef.resolve();
let timeRange = panel.state.$timeRange;
if (!(timeRange instanceof PanelTimeRange)) {
timeRange = new PanelTimeRange();
}
timeRange.setState({
timeFrom: this.state.timeFrom,
timeShift: this.state.timeShift,
hideTimeOverride: this.state.hideTimeOverride,
compareWith: this.state.compareWith,
zoomBehavior: this.state.zoomBehavior,
});
if (!panel.state.$timeRange) {
panel.setState({ $timeRange: timeRange });
const queryRunner = getQueryRunnerFor(panel);
queryRunner?.runQueries();
}
this.onClose();
};
static Component = ({ model }: SceneComponentProps<PanelTimeRangeDrawer>) => {
const { timeFrom, timeShift, compareWith, hideTimeOverride } = model.useState();
const timeOptions = getQuickOptions()
.filter((o) => {
// Filter out time options that are not relative to now as we do not have persitance support for those yet
return o.to === 'now';
})
.map((option, index) => ({ label: option.display, value: option.from }));
timeOptions.unshift({ label: t('common.disabled', 'Disabled'), value: '' });
const timeShiftOptions = [
{ label: t('common.disabled', 'Disabled'), value: '' },
{ label: t('time-period.1_hour', '1 hour'), value: '1h' },
{ label: t('time-period.6_hours', '6 hours'), value: '6h' },
{ label: t('time-period.12_hours', '12 hours'), value: '12h' },
{ label: t('time-period.1_day', '1 day'), value: '24h' },
{ label: t('time-period.7_days', '7 days'), value: '7d' },
{ label: t('time-period.30_days', '30 days'), value: '30d' },
];
return (
<Drawer
title={t('dashboard.panel.time-range-settings.title', 'Panel time range settings')}
onClose={model.onClose}
size="sm"
>
<Stack direction="column" gap={2}>
<Field
label={t('dashboard.panel.time-range-settings.time-from', 'Custom panel time range')}
noMargin
description={t(
'dashboard.panel.time-range-settings.time-from-description',
'Overrides the dashboard time range. To specify a value not found in the list just type in a custom value, for example 5m or 2h'
)}
>
<Stack>
<Combobox
options={timeOptions}
value={timeFrom ?? ''}
createCustomValue={true}
onChange={(x) => {
model.setState({ timeFrom: x.value });
}}
/>
</Stack>
</Field>
<Field
label={t('dashboard.panel.time-range-settings.time-shift', 'Time shift')}
noMargin
description={t(
'dashboard.panel.time-range-settings.time-shift-description',
'Adds a time shift relative to the dashboard or panel time range. To specify a value not found in the list just type in a custom value, for example 5m or 2h'
)}
>
<Combobox
options={timeShiftOptions}
value={timeShift ?? ''}
createCustomValue={true}
onChange={(x) => {
model.setState({ timeShift: x.value });
}}
/>
</Field>
{config.featureToggles.timeComparison && (
<Field
noMargin
label={
<Stack alignItems={'center'} justifyContent={'space-between'}>
<Label
description={t(
'dashboard.panel.time-range-settings.time-window-compare-description',
'Query and overlay data from a different time period'
)}
>
<Trans i18nKey="dashboard.panel.time-range-settings.time-window-compare">
Time window comparison
</Trans>
</Label>
<FeatureBadge featureState={FeatureState.new} />
</Stack>
}
>
<Combobox
options={DEFAULT_COMPARE_OPTIONS}
createCustomValue={true}
value={compareWith ?? ''}
onChange={(x) => model.setState({ compareWith: x.value })}
/>
</Field>
)}
<Field
noMargin
label={t('dashboard.panel.time-range-settings.hide-time-info', 'Hidden time info')}
description={t(
'dashboard.panel.time-range-settings.hide-time-info-description',
'Do not show the custom time range in the panel header'
)}
>
<Switch
value={Boolean(hideTimeOverride)}
onChange={(x) => model.setState({ hideTimeOverride: x.currentTarget.checked })}
/>
</Field>
<Box paddingTop={3}>
<Stack>
<Button variant="secondary" onClick={model.onClose}>
<Trans i18nKey="common.cancel">Cancel</Trans>
</Button>
<Button variant="primary" onClick={model.onApply}>
<Trans i18nKey="common.apply">Apply</Trans>
</Button>
</Stack>
</Box>
</Stack>
</Drawer>
);
};
}
@@ -0,0 +1,56 @@
// Processor function for use with time shifted comparison series.
// This aligns the secondary series with the primary and adds custom
// metadata and config to the secondary series' fields so that it is
import { of } from 'rxjs';
import { dateTime, DateTime, rangeUtil, TimeRange } from '@grafana/data';
import { ExtraQueryDataProcessor } from '@grafana/scenes';
// rendered appropriately.
export const timeShiftAlignmentProcessor: ExtraQueryDataProcessor = (primary, secondary) => {
const diff = secondary.timeRange.from.diff(primary.timeRange.from);
secondary.series.forEach((series) => {
series.refId = getCompareSeriesRefId(series.refId || '');
series.meta = {
...series.meta,
// @ts-ignore Remove when https://github.com/grafana/grafana/pull/71129 is released
timeCompare: {
diffMs: diff,
isTimeShiftQuery: true,
},
};
});
return of(secondary);
};
export const getCompareSeriesRefId = (refId: string) => `${refId}-compare`;
const PREVIOUS_PERIOD_VALUE = '__previousPeriod';
export function getCompareTimeRange(timeRange: TimeRange, compareWith: string | undefined): TimeRange | undefined {
let compareFrom: DateTime;
let compareTo: DateTime;
if (compareWith) {
if (compareWith === PREVIOUS_PERIOD_VALUE) {
const diffMs = timeRange.to.diff(timeRange.from);
compareFrom = dateTime(timeRange.from!).subtract(diffMs);
compareTo = dateTime(timeRange.to!).subtract(diffMs);
} else {
compareFrom = dateTime(timeRange.from!).subtract(rangeUtil.intervalToMs(compareWith));
compareTo = dateTime(timeRange.to!).subtract(rangeUtil.intervalToMs(compareWith));
}
return {
from: compareFrom,
to: compareTo,
raw: {
from: compareFrom,
to: compareTo,
},
};
}
return undefined;
}
@@ -1,8 +1,6 @@
import { defaultDataQueryKind, PanelQueryKind, PanelKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { CustomTimeRangeCompare } from '../../scene/CustomTimeRangeCompare';
import { buildVizPanel, getRuntimePanelDataSource } from './utils';
import { getRuntimePanelDataSource } from './utils';
// Mock the config needed for the function
jest.mock('@grafana/runtime', () => ({
@@ -42,76 +40,6 @@ jest.mock('@grafana/runtime', () => ({
},
}));
// Mock only what's essential for header actions tests
jest.mock('../../scene/CustomTimeRangeCompare', () => ({
CustomTimeRangeCompare: jest.fn(),
}));
// Helper function to create a minimal panel for testing
const createTestPanel = (): PanelKind => ({
kind: 'Panel',
spec: {
id: 1,
title: 'Test Panel',
description: '',
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
options: {},
fieldConfig: { defaults: {}, overrides: [] },
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [],
queryOptions: {},
transformations: [],
},
},
links: [],
},
});
describe('buildVizPanel', () => {
describe('header actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should include CustomTimeRangeCompare in headerActions when timeComparison feature toggle is enabled', () => {
// Mock config with timeComparison enabled
const mockConfig = require('@grafana/runtime').config;
mockConfig.featureToggles.timeComparison = true;
const panel = createTestPanel();
const vizPanel = buildVizPanel(panel);
expect(vizPanel.state.headerActions).toBeDefined();
expect(vizPanel.state.headerActions).toHaveLength(1);
expect(CustomTimeRangeCompare).toHaveBeenCalledWith({
key: 'time-compare',
compareWith: undefined,
compareOptions: [],
});
});
it('should not include headerActions when timeComparison feature toggle is disabled', () => {
// Mock config with timeComparison disabled
const mockConfig = require('@grafana/runtime').config;
mockConfig.featureToggles.timeComparison = false;
const panel = createTestPanel();
const vizPanel = buildVizPanel(panel);
expect(vizPanel.state.headerActions).toBeUndefined();
expect(CustomTimeRangeCompare).not.toHaveBeenCalled();
});
});
});
describe('getRuntimePanelDataSource', () => {
it('should return the datasource when it is specified in the query', () => {
const query: PanelQueryKind = {
@@ -24,16 +24,15 @@ import {
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { CustomTimeRangeCompare } from '../../scene/CustomTimeRangeCompare';
import { DashboardDatasourceBehaviour } from '../../scene/DashboardDatasourceBehaviour';
import { DashboardScene } from '../../scene/DashboardScene';
import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior';
import { PanelNotices } from '../../scene/PanelNotices';
import { PanelTimeRange } from '../../scene/PanelTimeRange';
import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem';
import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem';
import { PanelTimeRange } from '../../scene/panel-timerange/PanelTimeRange';
import { setDashboardPanelContext } from '../../scene/setDashboardPanelContext';
import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager';
import { getVizPanelKeyForPanelId } from '../../utils/utils';
@@ -72,10 +71,6 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel {
titleItems,
$behaviors: [],
extendPanelContext: setDashboardPanelContext,
// _UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel), //FIXME: Angular Migration
headerActions: config.featureToggles.timeComparison
? [new CustomTimeRangeCompare({ key: 'time-compare', compareWith: undefined, compareOptions: [] })]
: undefined,
};
if (!config.publicDashboardAccessToken) {
@@ -29,11 +29,11 @@ import { DashboardDataDTO } from 'app/types/dashboard';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { PanelTimeRange } from '../scene/panel-timerange/PanelTimeRange';
import { NEW_LINK } from '../settings/links/utils';
import { getQueryRunnerFor } from '../utils/utils';
@@ -810,41 +810,6 @@ describe('transformSaveModelToScene', () => {
expect((libPanelBehavior as LibraryPanelBehavior).state.name).toEqual(panel.libraryPanel.name);
expect(gridItem.state.body.state.title).toEqual(panel.title);
});
describe('header actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should include headerActions when timeComparison feature toggle is enabled', () => {
config.featureToggles.timeComparison = true;
const panel = {
title: 'Test Panel',
type: 'timeseries',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
};
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.headerActions).toBeDefined();
expect(vizPanel.state.headerActions).toHaveLength(1);
});
it('should not include headerActions when timeComparison feature toggle is disabled', () => {
config.featureToggles.timeComparison = false;
const panel = {
title: 'Test Panel',
type: 'timeseries',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
};
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.headerActions).toBeUndefined();
});
});
});
describe('Convert to new rows', () => {
@@ -33,7 +33,6 @@ import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { CustomTimeRangeCompare } from '../scene/CustomTimeRangeCompare';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
@@ -44,7 +43,6 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
@@ -52,6 +50,7 @@ import { RowActions } from '../scene/layout-default/row-actions/RowActions';
import { RowItem } from '../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { getIsLazy } from '../scene/layouts-shared/utils';
import { PanelTimeRange } from '../scene/panel-timerange/PanelTimeRange';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
@@ -428,9 +427,6 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
$behaviors: [],
extendPanelContext: setDashboardPanelContext,
_UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel),
headerActions: config.featureToggles.timeComparison
? [new CustomTimeRangeCompare({ key: 'time-compare', compareWith: undefined, compareOptions: [] })]
: undefined,
};
if (panel.libraryPanel) {
@@ -32,10 +32,10 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { PanelTimeRange } from '../scene/panel-timerange/PanelTimeRange';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { djb2Hash } from '../utils/djb2Hash';
import {
@@ -48,7 +48,7 @@ import {
} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { PanelTimeRange } from '../scene/panel-timerange/PanelTimeRange';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getLibraryPanelBehavior, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils';
@@ -5,7 +5,7 @@ import { ShareEmbed } from 'app/features/dashboard/components/ShareModal/ShareEm
import { buildParams, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { DashboardScene } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { PanelTimeRange } from '../scene/panel-timerange/PanelTimeRange';
import { getDashboardUrl } from '../utils/getDashboardUrl';
import { getDashboardSceneFor } from '../utils/utils';
@@ -17,7 +17,6 @@ import { Dashboard, Panel, RowPanel } from '@grafana/schema';
import { createLogger } from '@grafana/ui';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
import { CustomTimeRangeCompare } from '../scene/CustomTimeRangeCompare';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
import { DashboardLayoutOrchestrator } from '../scene/DashboardLayoutOrchestrator';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
@@ -256,9 +255,6 @@ export function getDefaultVizPanel(): VizPanel {
menu: new VizPanelMenu({
$behaviors: [panelMenuBehavior],
}),
headerActions: config.featureToggles.timeComparison
? [new CustomTimeRangeCompare({ key: 'time-compare', compareWith: undefined, compareOptions: [] })]
: undefined,
$data: new SceneDataTransformer({
$data: new SceneQueryRunner({
queries: [{ refId: 'A' }],
@@ -1,6 +1,5 @@
import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { commonOptionsBuilder } from '@grafana/ui';
import { optsWithHideZeros } from '@grafana/ui/internal';
@@ -18,10 +17,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TimeSeriesPanel)
commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros);
commonOptionsBuilder.addLegendOptions(builder);
if (config.featureToggles.timeComparison && config.featureToggles.dashboardScene) {
commonOptionsBuilder.addTimeCompareOption(builder);
}
builder.addCustomEditor({
id: 'timezone',
name: t('timeseries.name-time-zone', 'Time zone'),
+23
View File
@@ -4157,6 +4157,7 @@
"cancel": "Cancel",
"clear": "Clear",
"collapse": "Collapse",
"disabled": "Disabled",
"edit": "Edit",
"help": "Help",
"loading": "Loading...",
@@ -5133,6 +5134,19 @@
"aria-label-remove-override": "Remove override",
"tooltip-remove-override": "Remove override"
},
"panel": {
"time-range-settings": {
"hide-time-info": "Hidden time info",
"hide-time-info-description": "Do not show the custom time range in the panel header",
"time-from": "Custom panel time range",
"time-from-description": "Overrides the dashboard time range. To specify a value not found in the list just type in a custom value, for example 5m or 2h",
"time-shift": "Time shift",
"time-shift-description": "Adds a time shift relative to the dashboard or panel time range. To specify a value not found in the list just type in a custom value, for example 5m or 2h",
"time-window-compare": "Time window comparison",
"time-window-compare-description": "Query and overlay data from a different time period",
"title": "Panel time range settings"
}
},
"panel-edit": {
"alerting-tab": {
"dashboard-not-saved": "Dashboard must be saved before alerts can be added.",
@@ -10872,6 +10886,7 @@
"replace-library-panel": "Replace library panel",
"share": "Share",
"show-legend": "Show legend",
"time-settings": "Time settings",
"unlink-library-panel": "Unlink library panel",
"view": "View"
},
@@ -13135,6 +13150,14 @@
"title": "Panel"
}
},
"time-period": {
"1_day": "1 day",
"1_hour": "1 hour",
"12_hours": "12 hours",
"30_days": "30 days",
"6_hours": "6 hours",
"7_days": "7 days"
},
"time-picker": {
"absolute": {
"recent-title": "Recently used absolute ranges",