diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index a4f953ae7d9..0ec2247a52b 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -22,6 +22,8 @@ export const Components = { fromField: 'data-testid Time Range from field', toField: 'data-testid Time Range to field', applyTimeRange: 'data-testid TimePicker submit button', + copyTimeRange: 'data-testid TimePicker copy button', + pasteTimeRange: 'data-testid TimePicker paste button', calendar: { label: 'data-testid Time Range calendar', openButton: 'data-testid Open time range calendar', diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index f350accba92..0954bd99512 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -39,6 +39,7 @@ export interface TimeRangePickerProps { onMoveBackward: () => void; onMoveForward: () => void; onZoom: () => void; + onError?: (error?: string) => void; history?: TimeRange[]; hideQuickRanges?: boolean; widthOverride?: number; @@ -58,6 +59,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) { onMoveBackward, onMoveForward, onZoom, + onError, timeZone, fiscalYearStartMonth, timeSyncButton, @@ -165,6 +167,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) { onChangeTimeZone={onChangeTimeZone} onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth} hideQuickRanges={hideQuickRanges} + onError={onError} /> diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx index a0bc70b19cc..36de6fd4308 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx @@ -22,6 +22,7 @@ interface Props { onChange: (timeRange: TimeRange) => void; onChangeTimeZone: (timeZone: TimeZone) => void; onChangeFiscalYearStartMonth?: (month: number) => void; + onError?: (error?: string) => void; timeZone?: TimeZone; fiscalYearStartMonth?: number; quickOptions?: TimeOption[]; @@ -122,7 +123,7 @@ export const TimePickerContent = (props: Props) => { }; const NarrowScreenForm = (props: FormProps) => { - const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory } = props; + const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory, onError } = props; const styles = useStyles2(getNarrowScreenStyles); const isAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to); const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute); @@ -156,7 +157,13 @@ const NarrowScreenForm = (props: FormProps) => { {!collapsed && (
- +
{showHistory && ( { }; const FullScreenForm = (props: FormProps) => { - const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions } = props; + const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions, onError } = props; const styles = useStyles2(getFullScreenStyles, props.hideQuickRanges); const onChangeTimeOption = (timeOption: TimeOption) => { return onChange(mapOptionToTimeRange(timeOption, timeZone)); @@ -194,6 +201,7 @@ const FullScreenForm = (props: FormProps) => { onApply={onChange} isFullscreen={true} isReversed={isReversed} + onError={onError} />
{props.showHistory && ( diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx index a03b7c5ea7d..40a1964b224 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, RenderResult } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { dateTimeParse, TimeRange } from '@grafana/data'; @@ -10,6 +11,15 @@ type TimeRangeFormRenderResult = RenderResult & { getCalendarDayByLabelText(label: string): HTMLButtonElement; }; +const mockClipboard = { + writeText: jest.fn(), + readText: jest.fn(), +}; + +Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, +}); + const defaultTimeRange: TimeRange = { from: dateTimeParse('2021-06-17 00:00:00', { timeZone: 'utc' }), to: dateTimeParse('2021-06-19 23:59:00', { timeZone: 'utc' }), @@ -19,6 +29,11 @@ const defaultTimeRange: TimeRange = { }, }; +const customRawTimeRange = { + from: '2023-06-17 00:00:00', + to: '2023-06-19 23:59:00', +}; + function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRangeFormRenderResult { const result = render( {}} timeZone={timeZone} /> @@ -34,7 +49,7 @@ function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRan } describe('TimeRangeForm', () => { - it('should render form correcty', () => { + it('should render form correctly', () => { const { getByLabelText, getByText, getAllByRole } = setup(); expect(getByText('Apply time range')).toBeInTheDocument(); @@ -105,6 +120,26 @@ describe('TimeRangeForm', () => { expect(to).toHaveClass('react-calendar__tile--rangeEnd'); }); + it('should copy time range to clipboard', async () => { + const { getByTestId } = setup(); + + await userEvent.click(getByTestId('data-testid TimePicker copy button')); + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith( + JSON.stringify({ from: defaultTimeRange.raw.from, to: defaultTimeRange.raw.to }) + ); + }); + + it('should paste time range from clipboard', async () => { + const { getByTestId, getByLabelText } = setup(); + + mockClipboard.readText.mockResolvedValue(JSON.stringify(customRawTimeRange)); + + await userEvent.click(getByTestId('data-testid TimePicker paste button')); + + expect(getByLabelText('From')).toHaveValue(customRawTimeRange.from); + expect(getByLabelText('To')).toHaveValue(customRawTimeRange.to); + }); + describe('dates error handling', () => { it('should show error on invalid dates', () => { const invalidTimeRange: TimeRange = { diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index 4cbf3748b4d..d5a53aa10f3 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -33,6 +33,7 @@ interface Props { fiscalYearStartMonth?: number; roundup?: boolean; isReversed?: boolean; + onError?: (error?: string) => void; } interface InputState { @@ -47,7 +48,15 @@ const ERROR_MESSAGES = { }; export const TimeRangeContent = (props: Props) => { - const { value, isFullscreen = false, timeZone, onApply: onApplyFromProps, isReversed, fiscalYearStartMonth } = props; + const { + value, + isFullscreen = false, + timeZone, + onApply: onApplyFromProps, + isReversed, + fiscalYearStartMonth, + onError, + } = props; const [fromValue, toValue] = valueToState(value.raw.from, value.raw.to, timeZone); const style = useStyles2(getStyles); @@ -99,6 +108,29 @@ export const TimeRangeContent = (props: Props) => { } }; + const onCopy = () => { + const raw: RawTimeRange = { from: from.value, to: to.value }; + navigator.clipboard.writeText(JSON.stringify(raw)); + }; + + const onPaste = async () => { + const raw = await navigator.clipboard.readText(); + let range; + + try { + range = JSON.parse(raw); + } catch (error) { + if (onError) { + onError(raw); + } + return; + } + + const [fromValue, toValue] = valueToState(range.from, range.to, timeZone); + setFrom(fromValue); + setTo(toValue); + }; + const fiscalYear = rangeUtil.convertRawToRange({ from: 'now/fy', to: 'now/fy' }, timeZone, fiscalYearStartMonth); const fiscalYearMessage = t('time-picker.range-content.fiscal-year', 'Fiscal year'); @@ -159,9 +191,27 @@ export const TimeRangeContent = (props: Props) => { {fyTooltip} - +
+ +
{ onAppendToHistory(value, values, onSaveToStore); props.onChange(value); }} + onError={(error?: string) => + appEvents.emit(AppEvents.alertError, [ + t('time-picker.copy-paste.default-error-title', 'Invalid time range'), + t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error }), + ]) + } /> ); }} diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index f13cf217004..45c38d23a6c 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -121,6 +121,14 @@ const getShortcuts = (modKey: string) => { 'Make time range absolute/permanent' ), }, + { + keys: ['t', 'c'], + description: t('help-modal.shortcuts-description.copy-time-range', 'Copy time range'), + }, + { + keys: ['t', 'v'], + description: t('help-modal.shortcuts-description.paste-time-range', 'Paste time range'), + }, ], }, ]; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 2f126c22145..6e6dc593475 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -18,6 +18,8 @@ import { ShowModalReactEvent, ZoomOutEvent, AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, } from '../../types/events'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; import { HelpModal } from '../components/help/HelpModal'; @@ -203,6 +205,14 @@ export class KeybindingSrv { this.bind('t right', () => { appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl })); }); + + this.bind('t c', () => { + appEvents.publish(new CopyTimeEvent()); + }); + + this.bind('t v', () => { + appEvents.publish(new PasteTimeEvent({ updateUrl })); + }); } setupDashboardBindings(dashboard: DashboardModel) { diff --git a/public/app/core/utils/timePicker.ts b/public/app/core/utils/timePicker.ts index 08dd76c2531..ba862fb1858 100644 --- a/public/app/core/utils/timePicker.ts +++ b/public/app/core/utils/timePicker.ts @@ -1,4 +1,6 @@ -import { TimeRange, toUtc, AbsoluteTimeRange } from '@grafana/data'; +import { TimeRange, toUtc, AbsoluteTimeRange, RawTimeRange } from '@grafana/data'; + +type CopiedTimeRangeResult = { range: RawTimeRange; isError: false } | { range: string; isError: true }; export const getShiftedTimeRange = (direction: number, origRange: TimeRange): AbsoluteTimeRange => { const range = { @@ -38,3 +40,20 @@ export const getZoomedTimeRange = (range: TimeRange, factor: number): AbsoluteTi return { from, to }; }; + +export async function getCopiedTimeRange(): Promise { + const raw = await navigator.clipboard.readText(); + let range; + + try { + range = JSON.parse(raw); + + if (!range.from || !range.to) { + return { range: raw, isError: true }; + } + + return { range, isError: false }; + } catch (e) { + return { range: raw, isError: true }; + } +} diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index ff53e2ca189..8c3d3f7ac84 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -10,16 +10,25 @@ import { TimeRange, toUtc, IntervalValues, + AppEvents, } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; +import { t } from '@grafana/ui/src/utils/i18n'; import appEvents from 'app/core/app_events'; import { config } from 'app/core/config'; import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv'; -import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../types/events'; +import { + AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, + ShiftTimeEvent, + ShiftTimeEventDirection, + ZoomOutEvent, +} from '../../../types/events'; import { TimeModel } from '../state/TimeModel'; import { getRefreshFromUrl } from '../utils/getRefreshFromUrl'; @@ -51,6 +60,14 @@ export class TimeSrv { this.makeAbsoluteTime(e.payload.updateUrl); }); + appEvents.subscribe(CopyTimeEvent, () => { + this.copyTimeRangeToClipboard(); + }); + + appEvents.subscribe(PasteTimeEvent, (e) => { + this.pasteTimeRangeFromClipboard(e.payload.updateUrl); + }); + document.addEventListener('visibilitychange', () => { if (this.autoRefreshBlocked && document.visibilityState === 'visible') { this.autoRefreshBlocked = false; @@ -357,6 +374,30 @@ export class TimeSrv { this.setTime({ from, to }, updateUrl); } + copyTimeRangeToClipboard() { + const { raw } = this.timeRange(); + navigator.clipboard.writeText(JSON.stringify({ from: raw.from, to: raw.to })); + appEvents.emit(AppEvents.alertSuccess, [ + t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'), + ]); + } + + async pasteTimeRangeFromClipboard(updateUrl = true) { + const { range, isError } = await getCopiedTimeRange(); + + if (isError === true) { + appEvents.emit(AppEvents.alertError, [ + t('time-picker.copy-paste.default-error-title', 'Invalid time range'), + t('time-picker.copy-paste.default-error-message', '{{error}} is not a valid time range', { error: range }), + ]); + return; + } + + const { from, to } = range; + + this.setTime({ from, to }, updateUrl); + } + // isRefreshOutsideThreshold function calculates the difference between last refresh and now // if the difference is outside 5% of the current set time range then the function will return true // if the difference is within 5% of the current set time range then the function will return false diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx b/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx index da9d1b9969f..c58336af1c8 100644 --- a/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx +++ b/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx @@ -1,9 +1,16 @@ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { dateTime, EventBusSrv } from '@grafana/data'; import { getAppEvents } from '@grafana/runtime'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from 'app/types/events'; +import { + AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, + ShiftTimeEvent, + ShiftTimeEventDirection, + ZoomOutEvent, +} from 'app/types/events'; import { TestProvider } from '../../../../test/helpers/TestProvider'; import { configureStore } from '../../../store/configureStore'; @@ -22,6 +29,15 @@ jest.mock('@grafana/runtime', () => { }; }); +const mockClipboard = { + writeText: jest.fn(), + readText: jest.fn(), +}; + +Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, +}); + const NOW = new Date('2020-10-10T00:00:00.000Z'); function daysFromNow(daysDiff: number) { return new Date(NOW.getTime() + daysDiff * 86400000); @@ -111,4 +127,31 @@ describe('useKeyboardShortcuts', () => { expect(panes[1]!.absoluteRange.from).toBe(daysFromNow(-3).getTime()); expect(panes[1]!.absoluteRange.to).toBe(daysFromNow(1).getTime()); }); + + it('copies the time range from the left pane', () => { + const store = setup(); + + getAppEvents().publish(new CopyTimeEvent()); + + const fromValue = store.getState().explore.panes.left!.range.raw.from; + const toValue = store.getState().explore.panes.left!.range.raw.to; + + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify({ from: fromValue, to: toValue })); + }); + + it('pastes the time range to left pane', async () => { + const store = setup(); + + const fromValue = 'now-3d'; + const toValue = 'now'; + + mockClipboard.readText.mockResolvedValue(JSON.stringify({ from: fromValue, to: toValue })); + getAppEvents().publish(new PasteTimeEvent({ updateUrl: false })); + + await waitFor(() => { + const raw = store.getState().explore.panes.left!.range.raw; + expect(raw.from).toBe(fromValue); + expect(raw.to).toBe(toValue); + }); + }); }); diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.ts b/public/app/features/explore/hooks/useKeyboardShortcuts.ts index 48c5bc43208..bd90b13e3f2 100644 --- a/public/app/features/explore/hooks/useKeyboardShortcuts.ts +++ b/public/app/features/explore/hooks/useKeyboardShortcuts.ts @@ -4,9 +4,15 @@ import { Unsubscribable } from 'rxjs'; import { getAppEvents } from '@grafana/runtime'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useDispatch } from 'app/types'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events'; +import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events'; -import { makeAbsoluteTime, shiftTime, zoomOut } from '../state/time'; +import { + copyTimeRangeToClipboard, + makeAbsoluteTime, + pasteTimeRangeFromClipboard, + shiftTime, + zoomOut, +} from '../state/time'; export function useKeyboardShortcuts() { const { keybindings } = useGrafana(); @@ -35,6 +41,18 @@ export function useKeyboardShortcuts() { }) ); + tearDown.push( + getAppEvents().subscribe(CopyTimeEvent, () => { + dispatch(copyTimeRangeToClipboard()); + }) + ); + + tearDown.push( + getAppEvents().subscribe(PasteTimeEvent, () => { + dispatch(pasteTimeRangeFromClipboard()); + }) + ); + return () => { tearDown.forEach((u) => u.unsubscribe()); }; diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index 6284e98064e..38683f45bf4 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -1,10 +1,19 @@ import { AnyAction, createAction } from '@reduxjs/toolkit'; -import { AbsoluteTimeRange, dateTimeForTimeZone, LoadingState, RawTimeRange, TimeRange } from '@grafana/data'; +import { + AbsoluteTimeRange, + AppEvents, + dateTimeForTimeZone, + LoadingState, + RawTimeRange, + TimeRange, +} from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { RefreshPicker } from '@grafana/ui'; +import { t } from '@grafana/ui/src/utils/i18n'; +import appEvents from 'app/core/app_events'; import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore'; -import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { sortLogsResult } from 'app/features/logs/utils'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; @@ -166,6 +175,41 @@ export function zoomOut(scale: number): ThunkResult { }); } +export function copyTimeRangeToClipboard(): ThunkResult { + return (dispatch, getState) => { + const range = getState().explore.panes[Object.keys(getState().explore.panes)[0]]!.range.raw; + navigator.clipboard.writeText(JSON.stringify(range)); + + appEvents.emit(AppEvents.alertSuccess, [ + t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'), + ]); + }; +} + +export function pasteTimeRangeFromClipboard(): ThunkResult { + return async (dispatch, getState) => { + const { range, isError } = await getCopiedTimeRange(); + + if (isError === true) { + appEvents.emit(AppEvents.alertError, [ + t('time-picker.copy-paste.default-error-title', 'Invalid time range'), + t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error: range }), + ]); + return; + } + + const panesSynced = getState().explore.syncedTimes; + + if (panesSynced) { + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range })); + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[1], rawRange: range })); + return; + } + + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range })); + }; +} + /** * Reducer for an Explore area, to be used by the global Explore reducer. */ diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 6f3e86e5134..9d742e99e8a 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -160,6 +160,18 @@ export class ShiftTimeEvent extends BusEventWithPayload { static type = 'shift-time'; } +export class CopyTimeEvent extends BusEventBase { + static type = 'copy-time'; +} + +interface PasteTimeEventPayload { + updateUrl?: boolean; +} + +export class PasteTimeEvent extends BusEventWithPayload { + static type = 'paste-time'; +} + interface AbsoluteTimeEventPayload { updateUrl: boolean; } diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 8b8d1774af3..9bd9ccb9be1 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -586,6 +586,7 @@ "shortcuts-description": { "change-theme": "Thema ändern", "collapse-all-rows": "Alle Zeilen einklappen", + "copy-time-range": "", "dashboard-settings": "Dashboard-Einstellungen", "duplicate-panel": "Fenster duplizieren", "exit-edit/setting-views": "Ansicht beenden/einstellen", @@ -599,6 +600,7 @@ "move-time-range-forward": "Zeitbereich nach vorne verschieben", "open-search": "Suche öffnen", "open-shared-modal": "Geteilter Fenstermodus öffnen", + "paste-time-range": "", "refresh-all-panels": "Alle Fenster aktualisieren", "remove-panel": "Fenster entfernen", "save-dashboard": "Dashboard speichern", @@ -1394,6 +1396,13 @@ "empty-recent-list-info": "Es sieht so aus, als hätten Sie diesen Zeit-Auswähler noch nie benutzt. Sobald Sie einige Zeitintervalle eingeben, werden hier die zuletzt verwendeten Intervalle angezeigt.", "filter-placeholder": "Schnellbereiche suchen" }, + "copy-paste": { + "copy-success-message": "", + "default-error-message": "", + "default-error-title": "", + "tooltip-copy": "", + "tooltip-paste": "" + }, "footer": { "change-settings-button": "Zeiteinstellungen ändern", "fiscal-year-option": "Geschäftsjahr", diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b4263184b9d..24ad3f6f952 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -586,6 +586,7 @@ "shortcuts-description": { "change-theme": "Change theme", "collapse-all-rows": "Collapse all rows", + "copy-time-range": "Copy time range", "dashboard-settings": "Dashboard settings", "duplicate-panel": "Duplicate Panel", "exit-edit/setting-views": "Exit edit/setting views", @@ -599,6 +600,7 @@ "move-time-range-forward": "Move time range forward", "open-search": "Open search", "open-shared-modal": "Open Panel Share Modal", + "paste-time-range": "Paste time range", "refresh-all-panels": "Refresh all panels", "remove-panel": "Remove Panel", "save-dashboard": "Save dashboard", @@ -1394,6 +1396,13 @@ "empty-recent-list-info": "It looks like you haven't used this time picker before. As soon as you enter some time intervals, recently used intervals will appear here.", "filter-placeholder": "Search quick ranges" }, + "copy-paste": { + "copy-success-message": "Time range copied to clipboard", + "default-error-message": "{{error}} is not a valid time range", + "default-error-title": "Invalid time range", + "tooltip-copy": "Copy time range to clipboard", + "tooltip-paste": "Paste time range" + }, "footer": { "change-settings-button": "Change time settings", "fiscal-year-option": "Fiscal year", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index c74fcd3eebb..467288d40f2 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -591,6 +591,7 @@ "shortcuts-description": { "change-theme": "Cambiar tema", "collapse-all-rows": "Contraer todas las filas", + "copy-time-range": "", "dashboard-settings": "Ajustes del panel de control", "duplicate-panel": "Duplicar panel", "exit-edit/setting-views": "Salir de las opciones de edición/ajustes", @@ -604,6 +605,7 @@ "move-time-range-forward": "Mover el rango de tiempo hacia delante", "open-search": "Abrir búsqueda", "open-shared-modal": "Abrir el modo de panel compartido", + "paste-time-range": "", "refresh-all-panels": "Actualizar todos los paneles", "remove-panel": "Eliminar panel", "save-dashboard": "Guardar panel de control", @@ -1400,6 +1402,13 @@ "empty-recent-list-info": "Parece que no ha utilizado antes este selector de tiempo. En cuanto introduzca algún intervalo de tiempo, los intervalos que haya usado recientemente aparecerán aquí.", "filter-placeholder": "Buscar intervalos rápidos" }, + "copy-paste": { + "copy-success-message": "", + "default-error-message": "", + "default-error-title": "", + "tooltip-copy": "", + "tooltip-paste": "" + }, "footer": { "change-settings-button": "Cambiar ajustes de tiempo", "fiscal-year-option": "Ejercicio fiscal", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 0299020d77f..7d3e5ff837e 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -591,6 +591,7 @@ "shortcuts-description": { "change-theme": "Modifier le thème", "collapse-all-rows": "Réduire toutes les lignes", + "copy-time-range": "", "dashboard-settings": "Paramètres du tableau de bord", "duplicate-panel": "Dupliquer le panneau", "exit-edit/setting-views": "Quitter les affichages de modification/configuration", @@ -604,6 +605,7 @@ "move-time-range-forward": "Avancer la plage de temps", "open-search": "Ouvrir la recherche", "open-shared-modal": "Ouvrir le panneau de partage modal", + "paste-time-range": "", "refresh-all-panels": "Actualiser tous les panneaux", "remove-panel": "Retirer le panneau", "save-dashboard": "Enregistrer le tableau de bord", @@ -1400,6 +1402,13 @@ "empty-recent-list-info": "Il semble que vous n'ayez jamais utilisé ce sélecteur de temps dans le passé. Lorsque vous commencerez à utiliser des plages de temps, celles récemment utilisées apparaîtront ici.", "filter-placeholder": "Rechercher dans les plages rapides" }, + "copy-paste": { + "copy-success-message": "", + "default-error-message": "", + "default-error-title": "", + "tooltip-copy": "", + "tooltip-paste": "" + }, "footer": { "change-settings-button": "Modifier les paramètres de l'heure", "fiscal-year-option": "Exercice fiscal", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 527664d55ba..afd1b579502 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -586,6 +586,7 @@ "shortcuts-description": { "change-theme": "Cĥäʼnģę ŧĥęmę", "collapse-all-rows": "Cőľľäpşę äľľ řőŵş", + "copy-time-range": "Cőpy ŧįmę řäʼnģę", "dashboard-settings": "Đäşĥþőäřđ şęŧŧįʼnģş", "duplicate-panel": "Đūpľįčäŧę Päʼnęľ", "exit-edit/setting-views": "Ēχįŧ ęđįŧ/şęŧŧįʼnģ vįęŵş", @@ -599,6 +600,7 @@ "move-time-range-forward": "Mővę ŧįmę řäʼnģę ƒőřŵäřđ", "open-search": "Øpęʼn şęäřčĥ", "open-shared-modal": "Øpęʼn Päʼnęľ Ŝĥäřę Mőđäľ", + "paste-time-range": "Päşŧę ŧįmę řäʼnģę", "refresh-all-panels": "Ŗęƒřęşĥ äľľ päʼnęľş", "remove-panel": "Ŗęmővę Päʼnęľ", "save-dashboard": "Ŝävę đäşĥþőäřđ", @@ -1394,6 +1396,13 @@ "empty-recent-list-info": "Ĩŧ ľőőĸş ľįĸę yőū ĥävęʼn'ŧ ūşęđ ŧĥįş ŧįmę pįčĸęř þęƒőřę. Åş şőőʼn äş yőū ęʼnŧęř şőmę ŧįmę įʼnŧęřväľş, řęčęʼnŧľy ūşęđ įʼnŧęřväľş ŵįľľ äppęäř ĥęřę.", "filter-placeholder": "Ŝęäřčĥ qūįčĸ řäʼnģęş" }, + "copy-paste": { + "copy-success-message": "Ŧįmę řäʼnģę čőpįęđ ŧő čľįpþőäřđ", + "default-error-message": "{{error}} įş ʼnőŧ ä väľįđ ŧįmę řäʼnģę", + "default-error-title": "Ĩʼnväľįđ ŧįmę řäʼnģę", + "tooltip-copy": "Cőpy ŧįmę řäʼnģę ŧő čľįpþőäřđ", + "tooltip-paste": "Päşŧę ŧįmę řäʼnģę" + }, "footer": { "change-settings-button": "Cĥäʼnģę ŧįmę şęŧŧįʼnģş", "fiscal-year-option": "Fįşčäľ yęäř", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 13fef03e0cc..aec40e87453 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -581,6 +581,7 @@ "shortcuts-description": { "change-theme": "更改主题", "collapse-all-rows": "折叠所有行", + "copy-time-range": "", "dashboard-settings": "仪表板设置", "duplicate-panel": "复制面板", "exit-edit/setting-views": "退出编辑/设置视图", @@ -594,6 +595,7 @@ "move-time-range-forward": "向前移动时间范围", "open-search": "打开搜索", "open-shared-modal": "打开面板分享模式", + "paste-time-range": "", "refresh-all-panels": "刷新所有面板", "remove-panel": "删除面板", "save-dashboard": "保存仪表板", @@ -1388,6 +1390,13 @@ "empty-recent-list-info": "看起来您之前没有使用过这个时间选择器。一旦您输入了某些时间间隔,最近使用的间隔就会出现在此处。", "filter-placeholder": "搜索快速范围" }, + "copy-paste": { + "copy-success-message": "", + "default-error-message": "", + "default-error-title": "", + "tooltip-copy": "", + "tooltip-paste": "" + }, "footer": { "change-settings-button": "更改时间设置", "fiscal-year-option": "财政年度",