mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Grafana/ui: TimeRangePicker with automatic sync across instances (#94074)
This commit is contained in:
parent
5a4b051a91
commit
3bf3290340
@ -210,6 +210,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `rolePickerDrawer` | Enables the new role picker drawer design |
|
||||
| `pluginsSriChecks` | Enables SRI checks for plugin assets |
|
||||
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
|
||||
| `timeRangeProvider` | Enables time pickers sync |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
@ -221,4 +221,5 @@ export interface FeatureToggles {
|
||||
unifiedStorageSearch?: boolean;
|
||||
pluginsSriChecks?: boolean;
|
||||
unifiedStorageBigObjectsSupport?: boolean;
|
||||
timeRangeProvider?: boolean;
|
||||
}
|
||||
|
@ -0,0 +1,101 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { makeTimeRange } from '@grafana/data';
|
||||
|
||||
import { TimeRangeContextHookValue, TimeRangeProvider, useTimeRangeContext } from './TimeRangeContext';
|
||||
|
||||
// Should be fine to have this globally as single file should not be parallelized
|
||||
let context: TimeRangeContextHookValue | undefined = undefined;
|
||||
function onContextChange(val?: TimeRangeContextHookValue) {
|
||||
context = val;
|
||||
}
|
||||
|
||||
describe('TimeRangeProvider', () => {
|
||||
it('provides the context with default values', () => {
|
||||
render(
|
||||
<TimeRangeProvider>
|
||||
<TestComponent onContextChange={onContextChange} />
|
||||
</TimeRangeProvider>
|
||||
);
|
||||
|
||||
expect(context).toMatchObject({
|
||||
sync: expect.any(Function),
|
||||
unSync: expect.any(Function),
|
||||
syncPossible: false,
|
||||
synced: false,
|
||||
syncedValue: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('is possible to sync if 2 instances exist', async () => {
|
||||
render(
|
||||
<TimeRangeProvider>
|
||||
<TestComponent onContextChange={onContextChange} />
|
||||
<TestComponent onContextChange={() => {}} />
|
||||
</TimeRangeProvider>
|
||||
);
|
||||
|
||||
expect(context).toMatchObject({
|
||||
sync: expect.any(Function),
|
||||
unSync: expect.any(Function),
|
||||
syncPossible: true,
|
||||
synced: false,
|
||||
syncedValue: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs and unsyncs time across instances', async () => {
|
||||
let context2: TimeRangeContextHookValue | undefined = undefined;
|
||||
function onContextChange2(val?: TimeRangeContextHookValue) {
|
||||
context2 = val;
|
||||
}
|
||||
|
||||
render(
|
||||
<TimeRangeProvider>
|
||||
<TestComponent onContextChange={onContextChange} />
|
||||
<TestComponent onContextChange={onContextChange2} />
|
||||
</TimeRangeProvider>
|
||||
);
|
||||
|
||||
const timeRange = makeTimeRange('2021-01-01', '2021-01-02');
|
||||
act(() => {
|
||||
context?.sync(timeRange);
|
||||
});
|
||||
|
||||
expect(context2).toMatchObject({
|
||||
syncPossible: true,
|
||||
synced: true,
|
||||
syncedValue: timeRange,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
context?.unSync();
|
||||
});
|
||||
|
||||
expect(context2).toMatchObject({
|
||||
syncPossible: true,
|
||||
synced: false,
|
||||
syncedValue: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTimeRangeContext', () => {
|
||||
it('does not error without provider', () => {
|
||||
render(<TestComponent onContextChange={onContextChange} />);
|
||||
expect(context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
type TestComponentProps = {
|
||||
onContextChange: (context?: TimeRangeContextHookValue) => void;
|
||||
};
|
||||
|
||||
function TestComponent(props: TestComponentProps) {
|
||||
const context = useTimeRangeContext();
|
||||
useEffect(() => {
|
||||
props.onContextChange(context);
|
||||
}, [context, props]);
|
||||
return null;
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
import { ReactNode, createContext, useEffect, useMemo, useState, useContext } from 'react';
|
||||
|
||||
import { TimeRange } from '@grafana/data';
|
||||
|
||||
type TimeRangeContextValue = TimeRangeContextHookValue & {
|
||||
// These are to be used internally and aren't passed to the users of the hook.
|
||||
|
||||
// Called when picker is mounted to update the picker count.
|
||||
addPicker(): void;
|
||||
|
||||
// Called when picker is unmounted to update the picker count.
|
||||
removePicker(): void;
|
||||
};
|
||||
|
||||
export type TimeRangeContextHookValue = {
|
||||
// If the time range is synced, this is the value that all pickers should show.
|
||||
syncedValue?: TimeRange;
|
||||
|
||||
// If the time range is synced across all visible pickers.
|
||||
synced: boolean;
|
||||
|
||||
// If it is possible to sync the time range. This is based just on the number of pickers that are visible so if
|
||||
// there is only one, the sync button can be hidden.
|
||||
syncPossible: boolean;
|
||||
|
||||
// Action passed to the picker to interact with the sync state.
|
||||
// Sync the time range across all pickers with the provided value. Can be also used just to update a value when
|
||||
// already synced.
|
||||
sync(value: TimeRange): void;
|
||||
unSync(): void;
|
||||
};
|
||||
|
||||
const TimeRangeContext = createContext<TimeRangeContextValue | undefined>(undefined);
|
||||
|
||||
export function TimeRangeProvider({ children }: { children: ReactNode }) {
|
||||
// We simply keep the count of the pickers visible by letting them call the addPicker and removePicker functions.
|
||||
const [pickersCount, setPickersCount] = useState(0);
|
||||
const [syncedValue, setSyncedValue] = useState<TimeRange>();
|
||||
|
||||
const contextVal = useMemo(() => {
|
||||
return {
|
||||
sync: (value: TimeRange) => setSyncedValue(value),
|
||||
unSync: () => setSyncedValue(undefined),
|
||||
addPicker: () => setPickersCount((val) => val + 1),
|
||||
removePicker: () => setPickersCount((val) => val - 1),
|
||||
syncPossible: pickersCount > 1,
|
||||
synced: Boolean(syncedValue),
|
||||
syncedValue,
|
||||
};
|
||||
}, [pickersCount, syncedValue]);
|
||||
|
||||
return <TimeRangeContext.Provider value={contextVal}>{children}</TimeRangeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTimeRangeContext(initialSyncValue?: TimeRange): TimeRangeContextHookValue | undefined {
|
||||
const context = useContext(TimeRangeContext);
|
||||
|
||||
// Automatically add and remove the picker when the component mounts and unmounts or if context changes (but that
|
||||
// should not happen). We ignore the initialSyncValue to make this value really just an initial value and isn't a
|
||||
// prop by which you could control the picker.
|
||||
useEffect(() => {
|
||||
// We want the pickers to still function even if they are not used in a context. Not sure, this will be a common
|
||||
// usecase, but it does not seem like it will cost us anything.
|
||||
if (context) {
|
||||
context.addPicker();
|
||||
if (initialSyncValue) {
|
||||
context.sync(initialSyncValue);
|
||||
}
|
||||
return () => {
|
||||
context.removePicker();
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
// We want to do this only on mount and unmount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
// We want the pickers to still function even if they are not used in a context. Not sure, this will be a common
|
||||
// usecase, but it does not seem like it will cost us anything.
|
||||
if (!context) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// We just remove the addPicker/removePicker function as that is done automatically here and picker does not need
|
||||
// them.
|
||||
return {
|
||||
sync: context.sync,
|
||||
unSync: context.unSync,
|
||||
syncPossible: context.syncPossible,
|
||||
synced: context.synced,
|
||||
syncedValue: context.syncedValue,
|
||||
};
|
||||
}, [context]);
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { dateTime, TimeRange } from '@grafana/data';
|
||||
import { dateTime, makeTimeRange, TimeRange } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { TimeRangeProvider } from './TimeRangeContext';
|
||||
import { TimeRangePicker } from './TimeRangePicker';
|
||||
|
||||
const selectors = e2eSelectors.components.TimePicker;
|
||||
@ -32,6 +33,7 @@ describe('TimePicker', () => {
|
||||
|
||||
expect(container.queryByLabelText(/Time range selected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches overlay content visibility when toolbar button is clicked twice', async () => {
|
||||
render(
|
||||
<TimeRangePicker
|
||||
@ -53,4 +55,39 @@ describe('TimePicker', () => {
|
||||
await userEvent.click(openButton);
|
||||
expect(overlayContent).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a sync button if two are rendered inside a TimeRangeProvider', async () => {
|
||||
const onChange1 = jest.fn();
|
||||
const onChange2 = jest.fn();
|
||||
const value1 = makeTimeRange('2024-01-01T00:00:00Z', '2024-01-01T01:00:00Z');
|
||||
const value2 = makeTimeRange('2024-01-01T00:00:00Z', '2024-01-01T02:00:00Z');
|
||||
|
||||
render(
|
||||
<TimeRangeProvider>
|
||||
<TimeRangePicker
|
||||
onChangeTimeZone={() => {}}
|
||||
onChange={onChange1}
|
||||
value={value1}
|
||||
onMoveBackward={() => {}}
|
||||
onMoveForward={() => {}}
|
||||
onZoom={() => {}}
|
||||
/>
|
||||
<TimeRangePicker
|
||||
onChangeTimeZone={() => {}}
|
||||
onChange={onChange2}
|
||||
value={value2}
|
||||
onMoveBackward={() => {}}
|
||||
onMoveForward={() => {}}
|
||||
onZoom={() => {}}
|
||||
/>
|
||||
</TimeRangeProvider>
|
||||
);
|
||||
|
||||
const syncButtons = screen.getAllByLabelText('Sync times');
|
||||
expect(syncButtons.length).toBe(2);
|
||||
await userEvent.click(syncButtons[0]);
|
||||
expect(onChange2).toBeCalledWith(value1);
|
||||
const unsyncButtons = screen.getAllByLabelText('Un sync times');
|
||||
expect(unsyncButtons.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
@ -25,6 +25,7 @@ import { Tooltip } from '../Tooltip/Tooltip';
|
||||
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
|
||||
import { WeekStart } from './WeekStartPicker';
|
||||
import { quickOptions } from './options';
|
||||
import { useTimeSync } from './utils/useTimeSync';
|
||||
|
||||
/** @public */
|
||||
export interface TimeRangePickerProps {
|
||||
@ -32,8 +33,19 @@ export interface TimeRangePickerProps {
|
||||
value: TimeRange;
|
||||
timeZone?: TimeZone;
|
||||
fiscalYearStartMonth?: number;
|
||||
|
||||
/**
|
||||
* If you handle sync state between pickers yourself use this prop to pass the sync button component.
|
||||
* Otherwise, a default one will show automatically if sync is possible.
|
||||
*/
|
||||
timeSyncButton?: JSX.Element;
|
||||
|
||||
// Use to manually set the synced styles for the time range picker if you need to control the sync state yourself.
|
||||
isSynced?: boolean;
|
||||
|
||||
// Use to manually set the initial sync state for the time range picker. It will use the current value to sync.
|
||||
initialIsSynced?: boolean;
|
||||
|
||||
onChange: (timeRange: TimeRange) => void;
|
||||
onChangeTimeZone: (timeZone: TimeZone) => void;
|
||||
onChangeFiscalYearStartMonth?: (month: number) => void;
|
||||
@ -65,8 +77,6 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
|
||||
onError,
|
||||
timeZone,
|
||||
fiscalYearStartMonth,
|
||||
timeSyncButton,
|
||||
isSynced,
|
||||
history,
|
||||
onChangeTimeZone,
|
||||
onChangeFiscalYearStartMonth,
|
||||
@ -75,10 +85,19 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
|
||||
isOnCanvas,
|
||||
onToolbarTimePickerClick,
|
||||
weekStart,
|
||||
initialIsSynced,
|
||||
} = props;
|
||||
|
||||
const { onChangeWithSync, isSynced, timeSyncButton } = useTimeSync({
|
||||
initialIsSynced,
|
||||
value,
|
||||
onChangeProp: props.onChange,
|
||||
isSyncedProp: props.isSynced,
|
||||
timeSyncButtonProp: props.timeSyncButton,
|
||||
});
|
||||
|
||||
const onChange = (timeRange: TimeRange) => {
|
||||
props.onChange(timeRange);
|
||||
onChangeWithSync(timeRange);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,27 @@
|
||||
import { ToolbarButton } from '../ToolbarButton';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
interface TimeSyncButtonProps {
|
||||
isSynced: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TimeSyncButton(props: TimeSyncButtonProps) {
|
||||
const { onClick, isSynced } = props;
|
||||
|
||||
const syncTimesTooltip = () => {
|
||||
const tooltip = isSynced ? 'Unsync all views' : 'Sync all views to this time range';
|
||||
return <>{tooltip}</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={syncTimesTooltip} placement="bottom">
|
||||
<ToolbarButton
|
||||
icon="link"
|
||||
variant={isSynced ? 'active' : 'canvas'}
|
||||
aria-label={isSynced ? 'Un sync times' : 'Sync times'}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
|
||||
import { TimeRange } from '@grafana/data';
|
||||
|
||||
import { useTimeRangeContext } from '../TimeRangeContext';
|
||||
import { TimeSyncButton } from '../TimeSyncButton';
|
||||
|
||||
/**
|
||||
* Handle the behaviour of the time sync button and syncing the time range between pickers. It also takes care of
|
||||
* backward compatibility with the manually controlled isSynced prop.
|
||||
* @param options
|
||||
*/
|
||||
export function useTimeSync(options: {
|
||||
initialIsSynced?: boolean;
|
||||
value: TimeRange;
|
||||
isSyncedProp?: boolean;
|
||||
timeSyncButtonProp?: JSX.Element;
|
||||
onChangeProp: (value: TimeRange) => void;
|
||||
}) {
|
||||
const { value, onChangeProp, isSyncedProp, initialIsSynced, timeSyncButtonProp } = options;
|
||||
const timeRangeContext = useTimeRangeContext(initialIsSynced && value ? value : undefined);
|
||||
|
||||
// Destructure to make it easier to use in hook deps.
|
||||
const timeRangeContextSynced = timeRangeContext?.synced;
|
||||
const timeRangeContextSyncedValue = timeRangeContext?.syncedValue;
|
||||
const timeRangeContextSyncFunc = timeRangeContext?.sync;
|
||||
|
||||
// This is to determine if we should use the context to sync or not. This is for backward compatibility so that
|
||||
// Explore with multiple panes still works as it is controlling the sync state itself for now.
|
||||
const usingTimeRangeContext = Boolean(options.isSyncedProp === undefined && timeRangeContext);
|
||||
|
||||
// Create new onChange that handles propagating the value to the context if possible and synced is true.
|
||||
const onChangeWithSync = useCallback(
|
||||
(timeRange: TimeRange) => {
|
||||
onChangeProp(timeRange);
|
||||
if (usingTimeRangeContext && timeRangeContextSynced) {
|
||||
timeRangeContextSyncFunc?.(timeRange);
|
||||
}
|
||||
},
|
||||
[onChangeProp, usingTimeRangeContext, timeRangeContextSyncFunc, timeRangeContextSynced]
|
||||
);
|
||||
|
||||
const prevValue = usePrevious(value);
|
||||
const prevSyncedValue = usePrevious(timeRangeContext?.syncedValue);
|
||||
|
||||
// As timepicker is controlled component we need to sync the global sync value back to the parent with onChange
|
||||
// handler whenever the outside global value changes. We do it here while checking if we are actually supposed
|
||||
// to and making sure we don't go into a loop.
|
||||
useEffect(() => {
|
||||
// only react if we are actually synced
|
||||
if (usingTimeRangeContext && timeRangeContextSynced) {
|
||||
if (value !== prevValue && value !== timeRangeContextSyncedValue) {
|
||||
// The value changed outside the picker. To keep the sync working we need to update the synced value.
|
||||
timeRangeContextSyncFunc?.(value);
|
||||
} else if (
|
||||
timeRangeContextSyncedValue &&
|
||||
timeRangeContextSyncedValue !== prevSyncedValue &&
|
||||
timeRangeContextSyncedValue !== value
|
||||
) {
|
||||
// The global synced value changed, so we need to update the picker value.
|
||||
onChangeProp(timeRangeContextSyncedValue);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
usingTimeRangeContext,
|
||||
timeRangeContextSynced,
|
||||
timeRangeContextSyncedValue,
|
||||
timeRangeContextSyncFunc,
|
||||
prevSyncedValue,
|
||||
value,
|
||||
prevValue,
|
||||
onChangeProp,
|
||||
]);
|
||||
|
||||
// Decide if we are in synced state or not. This is complicated by the manual controlled isSynced prop that is used
|
||||
// in Explore for now.
|
||||
const isSynced = usingTimeRangeContext ? timeRangeContext?.synced : isSyncedProp;
|
||||
|
||||
// Again in Explore the sync button is controlled prop so here we also decide what kind of button to use.
|
||||
const button = usingTimeRangeContext
|
||||
? timeRangeContext?.syncPossible && (
|
||||
<TimeSyncButton
|
||||
isSynced={timeRangeContext?.synced}
|
||||
onClick={() => (timeRangeContext?.synced ? timeRangeContext.unSync() : timeRangeContext.sync(value))}
|
||||
/>
|
||||
)
|
||||
: timeSyncButtonProp;
|
||||
|
||||
return {
|
||||
onChangeWithSync,
|
||||
isSynced,
|
||||
timeSyncButton: button,
|
||||
};
|
||||
}
|
@ -35,6 +35,7 @@ export { UnitPicker } from './UnitPicker/UnitPicker';
|
||||
export { StatsPicker } from './StatsPicker/StatsPicker';
|
||||
export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker';
|
||||
export { TimeRangePicker, type TimeRangePickerProps } from './DateTimePickers/TimeRangePicker';
|
||||
export { TimeRangeProvider } from './DateTimePickers/TimeRangeContext';
|
||||
export { TimePickerTooltip } from './DateTimePickers/TimeRangePicker';
|
||||
export { TimeRangeLabel } from './DateTimePickers/TimeRangePicker/TimeRangeLabel';
|
||||
export { TimeOfDayPicker } from './DateTimePickers/TimeOfDayPicker';
|
||||
|
@ -1522,6 +1522,12 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
},
|
||||
{
|
||||
Name: "timeRangeProvider",
|
||||
Description: "Enables time pickers sync",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -202,3 +202,4 @@ rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
|
||||
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
|
||||
pluginsSriChecks,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
|
||||
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
|
|
@ -818,4 +818,8 @@ const (
|
||||
// FlagUnifiedStorageBigObjectsSupport
|
||||
// Enables to save big objects in blob storage
|
||||
FlagUnifiedStorageBigObjectsSupport = "unifiedStorageBigObjectsSupport"
|
||||
|
||||
// FlagTimeRangeProvider
|
||||
// Enables time pickers sync
|
||||
FlagTimeRangeProvider = "timeRangeProvider"
|
||||
)
|
||||
|
@ -2966,6 +2966,18 @@
|
||||
"allowSelfServe": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "timeRangeProvider",
|
||||
"resourceVersion": "1728565214224",
|
||||
"creationTimestamp": "2024-10-10T13:00:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables time pickers sync",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-frontend-platform"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "tlsMemcached",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Action, KBarProvider } from 'kbar';
|
||||
import { Component, ComponentType } from 'react';
|
||||
import { Component, ComponentType, Fragment } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
SidecarContext_EXPERIMENTAL,
|
||||
sidecarServiceSingleton_EXPERIMENTAL,
|
||||
} from '@grafana/runtime';
|
||||
import { ErrorBoundaryAlert, GlobalStyles, PortalContainer } from '@grafana/ui';
|
||||
import { ErrorBoundaryAlert, GlobalStyles, PortalContainer, TimeRangeProvider } from '@grafana/ui';
|
||||
import { getAppRoutes } from 'app/routes/routes';
|
||||
import { store } from 'app/store/store';
|
||||
|
||||
@ -90,6 +90,8 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
||||
bodyRenderHooks,
|
||||
};
|
||||
|
||||
const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment;
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ErrorBoundaryAlert style="page">
|
||||
@ -100,19 +102,21 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
||||
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
|
||||
>
|
||||
<GlobalStyles />
|
||||
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
|
||||
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}>
|
||||
<div className="grafana-app">
|
||||
{config.featureToggles.appSidecar ? (
|
||||
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
|
||||
) : (
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
)}
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
</ExtensionRegistriesProvider>
|
||||
</SidecarContext_EXPERIMENTAL.Provider>
|
||||
<MaybeTimeRangeProvider>
|
||||
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
|
||||
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}>
|
||||
<div className="grafana-app">
|
||||
{config.featureToggles.appSidecar ? (
|
||||
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
|
||||
) : (
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
)}
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
</ExtensionRegistriesProvider>
|
||||
</SidecarContext_EXPERIMENTAL.Provider>
|
||||
</MaybeTimeRangeProvider>
|
||||
</KBarProvider>
|
||||
</ThemeProvider>
|
||||
</GrafanaContext.Provider>
|
||||
|
Loading…
Reference in New Issue
Block a user