From 04398dcb099604238a2f8fcac9b420f7bbabd3d1 Mon Sep 17 00:00:00 2001 From: Galen Date: Thu, 12 Dec 2024 15:34:46 -0600 Subject: [PATCH 1/9] feat: keybindings - support copy paste time range with and --- src/services/keyboardShortcuts.ts | 50 +++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/services/keyboardShortcuts.ts b/src/services/keyboardShortcuts.ts index 00b3897e..bee999a6 100644 --- a/src/services/keyboardShortcuts.ts +++ b/src/services/keyboardShortcuts.ts @@ -1,8 +1,8 @@ import { IndexScene } from '../Components/IndexScene/IndexScene'; import { KeybindingSet } from './KeybindingSet'; import { getAppEvents, locationService } from '@grafana/runtime'; -import { SetPanelAttentionEvent } from '@grafana/data'; -import { sceneGraph, VizPanel } from '@grafana/scenes'; +import { BusEventBase, BusEventWithPayload, SetPanelAttentionEvent } from '@grafana/data'; +import { sceneGraph, SceneObject, sceneUtils, VizPanel } from '@grafana/scenes'; import { getExploreLink } from '../Components/Panels/PanelMenu'; import { getTimePicker } from './scenes'; import { OptionsWithLegend } from '@grafana/ui'; @@ -63,6 +63,25 @@ export function setupKeyboardShortcuts(scene: IndexScene) { }), }); + // Copy time range + keybindings.addBinding({ + key: 't c', + onTrigger: () => { + const timeRange = sceneGraph.getTimeRange(scene); + // + setWindowGrafanaSceneContext(timeRange); + appEvents.publish(new CopyTimeEvent()); + }, + }); + + // Paste time range + keybindings.addBinding({ + key: 't v', + onTrigger: () => { + appEvents.publish(new PasteTimeEvent({ updateUrl: true })); + }, + }); + // Refresh keybindings.addBinding({ key: 'd r', @@ -146,3 +165,30 @@ export function toggleVizPanelLegend(vizPanel: VizPanel): void { function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend { return optionsWithLegend != null && typeof optionsWithLegend === 'object' && 'legend' in optionsWithLegend; } +export class CopyTimeEvent extends BusEventBase { + static type = 'copy-time'; +} + +interface PasteTimeEventPayload { + updateUrl?: boolean; +} + +export class PasteTimeEvent extends BusEventWithPayload { + static type = 'paste-time'; +} + +/** + * @todo delete after https://github.com/grafana/scenes/pull/999 is available + * @param activeScene + */ +export function setWindowGrafanaSceneContext(activeScene: SceneObject) { + const prevScene = (window as any).__grafanaSceneContext; + + (window as any).__grafanaSceneContext = activeScene; + + return () => { + if ((window as any).__grafanaSceneContext === activeScene) { + (window as any).__grafanaSceneContext = prevScene; + } + }; +} From f7baf448178abe3d17cb725f6fa0c87359c5cd79 Mon Sep 17 00:00:00 2001 From: Galen Date: Thu, 12 Dec 2024 15:42:09 -0600 Subject: [PATCH 2/9] chore: document --- src/services/keyboardShortcuts.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/services/keyboardShortcuts.ts b/src/services/keyboardShortcuts.ts index bee999a6..2ac3eeee 100644 --- a/src/services/keyboardShortcuts.ts +++ b/src/services/keyboardShortcuts.ts @@ -165,14 +165,21 @@ export function toggleVizPanelLegend(vizPanel: VizPanel): void { function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend { return optionsWithLegend != null && typeof optionsWithLegend === 'object' && 'legend' in optionsWithLegend; } + +// Copied from https://github.com/grafana/grafana/blob/main/public/app/types/events.ts +// @todo export from core grafana export class CopyTimeEvent extends BusEventBase { static type = 'copy-time'; } +// Copied from https://github.com/grafana/grafana/blob/main/public/app/types/events.ts +// @todo export from core grafana interface PasteTimeEventPayload { updateUrl?: boolean; } +// Copied from https://github.com/grafana/grafana/blob/main/public/app/types/events.ts +// @todo export from core grafana export class PasteTimeEvent extends BusEventWithPayload { static type = 'paste-time'; } From 7cf2c49b83b813e4cb7b6f092c4e64d96d93c973 Mon Sep 17 00:00:00 2001 From: Galen Date: Thu, 12 Dec 2024 15:43:01 -0600 Subject: [PATCH 3/9] chore: cleanup --- src/services/keyboardShortcuts.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/keyboardShortcuts.ts b/src/services/keyboardShortcuts.ts index 2ac3eeee..8f3dfb14 100644 --- a/src/services/keyboardShortcuts.ts +++ b/src/services/keyboardShortcuts.ts @@ -68,7 +68,6 @@ export function setupKeyboardShortcuts(scene: IndexScene) { key: 't c', onTrigger: () => { const timeRange = sceneGraph.getTimeRange(scene); - // setWindowGrafanaSceneContext(timeRange); appEvents.publish(new CopyTimeEvent()); }, From 53cc870cd801f994ab54aea66373d3813d09328d Mon Sep 17 00:00:00 2001 From: Galen Date: Thu, 12 Dec 2024 16:35:50 -0600 Subject: [PATCH 4/9] chore: subscribe to PasteTimeEvent and update time range if valid --- src/Components/IndexScene/IndexScene.tsx | 30 +++++++++++++++++++- src/services/keyboardShortcuts.ts | 36 ++++++++++++++++++++++-- src/services/narrowing.ts | 6 ++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index 50201dbf..4cedbe18 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -75,7 +75,7 @@ import { AdHocFilterWithLabels } from '../../services/scenes'; import { FilterOp } from '../../services/filterTypes'; import { ShowLogsButtonScene } from './ShowLogsButtonScene'; import { CustomVariableValueSelectors } from './CustomVariableValueSelectors'; -import { setupKeyboardShortcuts } from '../../services/keyboardShortcuts'; +import { getCopiedTimeRange, PasteTimeEvent, setupKeyboardShortcuts } from '../../services/keyboardShortcuts'; export const showLogsButtonSceneKey = 'showLogsButtonScene'; export interface AppliedPattern { @@ -180,6 +180,7 @@ export class IndexScene extends SceneObjectBase { const timeRange = sceneGraph.getTimeRange(this); this._subs.add(timeRange.subscribeToState(this.limitMaxInterval(timeRange))); + this._subs.add(this.subscribeToEvent(PasteTimeEvent, this.subscribeToPasteTimeEvent)); const clearKeyBindings = setupKeyboardShortcuts(this); @@ -221,6 +222,33 @@ export class IndexScene extends SceneObjectBase { }); } + private subscribeToPasteTimeEvent = async () => { + const copiedRange = await getCopiedTimeRange(); + + if (!copiedRange.isError) { + const timeRange = sceneGraph.getTimeRange(this); + const to = typeof copiedRange.range.to === 'string' ? copiedRange.range.to : undefined; + const from = typeof copiedRange.range.from === 'string' ? copiedRange.range.from : undefined; + + const newRange = rangeUtil.convertRawToRange(copiedRange.range); + + if (timeRange && newRange) { + timeRange.setState({ + value: newRange, + to, + from, + }); + } else { + logger.error(new Error('Invalid time range from clipboard'), { + msg: 'Invalid time range from clipboard', + sceneTimeRange: typeof timeRange, + to: to ?? '', + from: from ?? '', + }); + } + } + }; + /** * If user selects a time range longer then the max configured interval, show toast and set the previous time range. * @param timeRange diff --git a/src/services/keyboardShortcuts.ts b/src/services/keyboardShortcuts.ts index 8f3dfb14..bd5d91ab 100644 --- a/src/services/keyboardShortcuts.ts +++ b/src/services/keyboardShortcuts.ts @@ -1,11 +1,12 @@ import { IndexScene } from '../Components/IndexScene/IndexScene'; import { KeybindingSet } from './KeybindingSet'; import { getAppEvents, locationService } from '@grafana/runtime'; -import { BusEventBase, BusEventWithPayload, SetPanelAttentionEvent } from '@grafana/data'; -import { sceneGraph, SceneObject, sceneUtils, VizPanel } from '@grafana/scenes'; +import { BusEventBase, BusEventWithPayload, RawTimeRange, SetPanelAttentionEvent } from '@grafana/data'; +import { sceneGraph, SceneObject, VizPanel } from '@grafana/scenes'; import { getExploreLink } from '../Components/Panels/PanelMenu'; import { getTimePicker } from './scenes'; import { OptionsWithLegend } from '@grafana/ui'; +import { hasProp, isObj, isString } from './narrowing'; const appEvents = getAppEvents(); @@ -77,7 +78,9 @@ export function setupKeyboardShortcuts(scene: IndexScene) { keybindings.addBinding({ key: 't v', onTrigger: () => { - appEvents.publish(new PasteTimeEvent({ updateUrl: true })); + const event = new PasteTimeEvent({ updateUrl: false }); + scene.publishEvent(event); + appEvents.publish(event); }, }); @@ -175,6 +178,7 @@ export class CopyTimeEvent extends BusEventBase { // @todo export from core grafana interface PasteTimeEventPayload { updateUrl?: boolean; + timeRange?: string; } // Copied from https://github.com/grafana/grafana/blob/main/public/app/types/events.ts @@ -198,3 +202,29 @@ export function setWindowGrafanaSceneContext(activeScene: SceneObject) { } }; } + +// taken from /Users/galen/projects/grafana/grafana/public/app/core/utils/timePicker.ts +type CopiedTimeRangeResult = { range: RawTimeRange; isError: false } | { range: string; isError: true }; + +// modified to narrow types from clipboard +export async function getCopiedTimeRange(): Promise { + const raw = await navigator.clipboard.readText(); + let unknownRange; + + try { + unknownRange = JSON.parse(raw); + + const range = isObj(unknownRange) && hasProp(unknownRange, 'to') && hasProp(unknownRange, 'from') && unknownRange; + if (range) { + const to = isString(range.to); + const from = isString(range.from); + if (to && from) { + return { range: { to, from }, isError: false }; + } + } + + return { range: raw, isError: true }; + } catch (e) { + return { range: raw, isError: true }; + } +} diff --git a/src/services/narrowing.ts b/src/services/narrowing.ts index 2d181a77..e6c5885e 100644 --- a/src/services/narrowing.ts +++ b/src/services/narrowing.ts @@ -1,13 +1,13 @@ import { SelectedTableRow } from '../Components/Table/LogLineCellComponent'; import { LogsVisualizationType } from './store'; import { FieldValue, ParserType } from './variables'; -const isObj = (o: unknown): o is object => typeof o === 'object' && o !== null; +export const isObj = (o: unknown): o is object => typeof o === 'object' && o !== null; -function hasProp(data: object, prop: K): data is Record { +export function hasProp(data: object, prop: K): data is Record { return prop in data; } -const isString = (s: unknown) => (typeof s === 'string' && s) || ''; +export const isString = (s: unknown) => (typeof s === 'string' && s) || ''; export const isRecord = (obj: unknown): obj is Record => typeof obj === 'object'; From 73acb8b28890cf149d5a9dee44942783fc3af26a Mon Sep 17 00:00:00 2001 From: Galen Date: Thu, 12 Dec 2024 16:46:08 -0600 Subject: [PATCH 5/9] chore: refactor narrowing --- src/services/keyboardShortcuts.ts | 21 ++++++--------------- src/services/narrowing.ts | 20 +++++++++++++++++--- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/services/keyboardShortcuts.ts b/src/services/keyboardShortcuts.ts index bd5d91ab..3e0385b2 100644 --- a/src/services/keyboardShortcuts.ts +++ b/src/services/keyboardShortcuts.ts @@ -6,7 +6,7 @@ import { sceneGraph, SceneObject, VizPanel } from '@grafana/scenes'; import { getExploreLink } from '../Components/Panels/PanelMenu'; import { getTimePicker } from './scenes'; import { OptionsWithLegend } from '@grafana/ui'; -import { hasProp, isObj, isString } from './narrowing'; +import { narrowTimeRange } from './narrowing'; const appEvents = getAppEvents(); @@ -205,26 +205,17 @@ export function setWindowGrafanaSceneContext(activeScene: SceneObject) { // taken from /Users/galen/projects/grafana/grafana/public/app/core/utils/timePicker.ts type CopiedTimeRangeResult = { range: RawTimeRange; isError: false } | { range: string; isError: true }; - // modified to narrow types from clipboard export async function getCopiedTimeRange(): Promise { const raw = await navigator.clipboard.readText(); - let unknownRange; + let unknownRange: unknown; try { unknownRange = JSON.parse(raw); - - const range = isObj(unknownRange) && hasProp(unknownRange, 'to') && hasProp(unknownRange, 'from') && unknownRange; + const range = narrowTimeRange(unknownRange); if (range) { - const to = isString(range.to); - const from = isString(range.from); - if (to && from) { - return { range: { to, from }, isError: false }; - } + return { isError: false, range }; } - - return { range: raw, isError: true }; - } catch (e) { - return { range: raw, isError: true }; - } + } catch (e) {} + return { range: raw, isError: true }; } diff --git a/src/services/narrowing.ts b/src/services/narrowing.ts index e6c5885e..4b43033d 100644 --- a/src/services/narrowing.ts +++ b/src/services/narrowing.ts @@ -1,13 +1,14 @@ import { SelectedTableRow } from '../Components/Table/LogLineCellComponent'; import { LogsVisualizationType } from './store'; import { FieldValue, ParserType } from './variables'; -export const isObj = (o: unknown): o is object => typeof o === 'object' && o !== null; +import { RawTimeRange } from '@grafana/data'; +const isObj = (o: unknown): o is object => typeof o === 'object' && o !== null; -export function hasProp(data: object, prop: K): data is Record { +function hasProp(data: object, prop: K): data is Record { return prop in data; } -export const isString = (s: unknown) => (typeof s === 'string' && s) || ''; +const isString = (s: unknown) => (typeof s === 'string' && s) || ''; export const isRecord = (obj: unknown): obj is Record => typeof obj === 'object'; @@ -80,4 +81,17 @@ export function narrowRecordStringNumber(o: unknown): Record | f return false; } +export function narrowTimeRange(unknownRange: unknown): RawTimeRange | undefined { + const range = isObj(unknownRange) && hasProp(unknownRange, 'to') && hasProp(unknownRange, 'from') && unknownRange; + if (range) { + const to = isString(range.to); + const from = isString(range.from); + if (to && from) { + return { to, from }; + } + } + + return undefined; +} + export class NarrowingError extends Error {} From b0be5872a4ca0a863aa1717d0be09ff8fafea31f Mon Sep 17 00:00:00 2001 From: Galen Date: Fri, 13 Dec 2024 11:08:42 -0600 Subject: [PATCH 6/9] chore: refactor --- src/Components/IndexScene/IndexScene.tsx | 39 ++++++++++++------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index 4cedbe18..d862f25f 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -226,26 +226,27 @@ export class IndexScene extends SceneObjectBase { const copiedRange = await getCopiedTimeRange(); if (!copiedRange.isError) { - const timeRange = sceneGraph.getTimeRange(this); - const to = typeof copiedRange.range.to === 'string' ? copiedRange.range.to : undefined; - const from = typeof copiedRange.range.from === 'string' ? copiedRange.range.from : undefined; - - const newRange = rangeUtil.convertRawToRange(copiedRange.range); + return; + } - if (timeRange && newRange) { - timeRange.setState({ - value: newRange, - to, - from, - }); - } else { - logger.error(new Error('Invalid time range from clipboard'), { - msg: 'Invalid time range from clipboard', - sceneTimeRange: typeof timeRange, - to: to ?? '', - from: from ?? '', - }); - } + const timeRange = sceneGraph.getTimeRange(this); + const to = typeof copiedRange.range.to === 'string' ? copiedRange.range.to : undefined; + const from = typeof copiedRange.range.from === 'string' ? copiedRange.range.from : undefined; + const newRange = rangeUtil.convertRawToRange(copiedRange.range); + + if (timeRange && newRange) { + timeRange.setState({ + value: newRange, + to, + from, + }); + } else { + logger.error(new Error('Invalid time range from clipboard'), { + msg: 'Invalid time range from clipboard', + sceneTimeRange: typeof timeRange, + to: to ?? '', + from: from ?? '', + }); } }; From 260310d59c054b936a21e5897bd8f56addd23895 Mon Sep 17 00:00:00 2001 From: Galen Date: Fri, 13 Dec 2024 11:35:34 -0600 Subject: [PATCH 7/9] chore: add test coverage --- src/services/keyboardShortcuts.test.ts | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/services/keyboardShortcuts.test.ts diff --git a/src/services/keyboardShortcuts.test.ts b/src/services/keyboardShortcuts.test.ts new file mode 100644 index 00000000..71489c2e --- /dev/null +++ b/src/services/keyboardShortcuts.test.ts @@ -0,0 +1,49 @@ +import { getCopiedTimeRange } from './keyboardShortcuts'; + +function mockReadText(value: any) { + Object.defineProperty(navigator, 'clipboard', { + // Allow overwriting + configurable: true, + value: { + // Provide mock implementation + readText: jest.fn().mockReturnValueOnce(Promise.resolve(value)), + }, + }); +} +describe('getCopiedTimeRange', () => { + it('should return valid absolute time range', async () => { + const inputString = `{"from":"2024-12-13T15:13:39.680Z","to":"2024-12-13T15:14:04.904Z"}`; + mockReadText(inputString); + + const expected = { + isError: false, + range: JSON.parse(inputString), + }; + + expect(await getCopiedTimeRange()).toEqual(expected); + }); + + it('should return valid relative time range', async () => { + const inputString = `{"from":"now-30m","to":"now"}`; + mockReadText(inputString); + + const expected = { + isError: false, + range: JSON.parse(inputString), + }; + + expect(await getCopiedTimeRange()).toEqual(expected); + }); + + it('should return error for non-timerange', async () => { + const inputString = `{"never":"gonna","give":"you", "up": true}`; + mockReadText(inputString); + + const expected = { + isError: true, + range: inputString, + }; + + expect(await getCopiedTimeRange()).toEqual(expected); + }); +}); From 36cf9960ad2514c628105f2b50de277ceddef9f7 Mon Sep 17 00:00:00 2001 From: Galen Date: Fri, 13 Dec 2024 11:42:01 -0600 Subject: [PATCH 8/9] fix: remove invalid negation --- src/Components/IndexScene/IndexScene.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index d862f25f..b0f867e2 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -225,7 +225,7 @@ export class IndexScene extends SceneObjectBase { private subscribeToPasteTimeEvent = async () => { const copiedRange = await getCopiedTimeRange(); - if (!copiedRange.isError) { + if (copiedRange.isError) { return; } From cb23163084a1a3d84f9e9eaac435a977d75e8b02 Mon Sep 17 00:00:00 2001 From: Galen Date: Fri, 13 Dec 2024 11:44:07 -0600 Subject: [PATCH 9/9] chore: docs --- src/services/keyboardShortcuts.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/services/keyboardShortcuts.ts b/src/services/keyboardShortcuts.ts index 3e0385b2..11a2b262 100644 --- a/src/services/keyboardShortcuts.ts +++ b/src/services/keyboardShortcuts.ts @@ -188,8 +188,12 @@ export class PasteTimeEvent extends BusEventWithPayload { } /** + * Adds the scene object to the global window state so that templateSrv in core can interpolate strings using the scene interpolation engine with the scene as scope. + * This is needed for old datasources that call templateSrv.replace without passing scopedVars. For example in DataSourceAPI.metricFindQuery. + * + * This is also used from TimeSrv to access scene time range. + * * @todo delete after https://github.com/grafana/scenes/pull/999 is available - * @param activeScene */ export function setWindowGrafanaSceneContext(activeScene: SceneObject) { const prevScene = (window as any).__grafanaSceneContext;