diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index 50201dbf..b0f867e2 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,34 @@ export class IndexScene extends SceneObjectBase { }); } + private subscribeToPasteTimeEvent = async () => { + const copiedRange = await getCopiedTimeRange(); + + if (copiedRange.isError) { + return; + } + + 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.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); + }); +}); diff --git a/src/services/keyboardShortcuts.ts b/src/services/keyboardShortcuts.ts index 00b3897e..11a2b262 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 { SetPanelAttentionEvent } from '@grafana/data'; -import { sceneGraph, 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 { narrowTimeRange } from './narrowing'; const appEvents = getAppEvents(); @@ -63,6 +64,26 @@ 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: () => { + const event = new PasteTimeEvent({ updateUrl: false }); + scene.publishEvent(event); + appEvents.publish(event); + }, + }); + // Refresh keybindings.addBinding({ key: 'd r', @@ -146,3 +167,59 @@ 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; + timeRange?: string; +} + +// 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'; +} + +/** + * 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 + */ +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; + } + }; +} + +// 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: unknown; + + try { + unknownRange = JSON.parse(raw); + const range = narrowTimeRange(unknownRange); + if (range) { + return { isError: false, range }; + } + } catch (e) {} + return { range: raw, isError: true }; +} diff --git a/src/services/narrowing.ts b/src/services/narrowing.ts index 2d181a77..4b43033d 100644 --- a/src/services/narrowing.ts +++ b/src/services/narrowing.ts @@ -1,6 +1,7 @@ import { SelectedTableRow } from '../Components/Table/LogLineCellComponent'; import { LogsVisualizationType } from './store'; import { FieldValue, ParserType } from './variables'; +import { RawTimeRange } from '@grafana/data'; const isObj = (o: unknown): o is object => typeof o === 'object' && o !== null; function hasProp(data: object, prop: K): data is Record { @@ -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 {}