Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keybindings: support time range copy/paste #960

Merged
merged 10 commits into from
Dec 13, 2024
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);
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
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> {
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
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 {}
Loading