Skip to content

Commit

Permalink
Keybindings: support time range copy/paste (#960)
Browse files Browse the repository at this point in the history
* feat: keybindings - support copy paste time range
  • Loading branch information
gtk-grafana authored and shantanualsi committed Dec 17, 2024
1 parent e52ae58 commit ff863dd
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 3 deletions.
31 changes: 30 additions & 1 deletion src/Components/IndexScene/IndexScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -180,6 +180,7 @@ export class IndexScene extends SceneObjectBase<IndexSceneState> {
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);

Expand Down Expand Up @@ -221,6 +222,34 @@ export class IndexScene extends SceneObjectBase<IndexSceneState> {
});
}

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
Expand Down
49 changes: 49 additions & 0 deletions src/services/keyboardShortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
81 changes: 79 additions & 2 deletions src/services/keyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<PasteTimeEventPayload> {
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<CopiedTimeRangeResult> {
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 };
}
14 changes: 14 additions & 0 deletions src/services/narrowing.ts
Original file line number Diff line number Diff line change
@@ -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<K extends PropertyKey>(data: object, prop: K): data is Record<K, unknown> {
Expand Down Expand Up @@ -80,4 +81,17 @@ export function narrowRecordStringNumber(o: unknown): Record<string, number> | 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 {}

0 comments on commit ff863dd

Please sign in to comment.