Sidecar: Remove experimental Sidecar implementation (#103786)

* Sidecar: remove `appSidecar` feature toggle

* Sidecar: Remove sidecar implementation

* lint
This commit is contained in:
Sven Grossmann 2025-04-10 22:04:12 +02:00 committed by GitHub
parent 0bad0526f5
commit 05338838b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 16 additions and 728 deletions

View File

@ -190,7 +190,6 @@ Experimental features might be changed or removed without prior notice.
| `exploreLogsAggregatedMetrics` | Used in Logs Drilldown to query by aggregated metrics |
| `exploreLogsLimitedTimeRange` | Used in Logs Drilldown to limit the time range |
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
| `rolePickerDrawer` | Enables the new role picker drawer design |
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
| `timeRangeProvider` | Enables time pickers sync |

View File

@ -725,10 +725,6 @@ export interface FeatureToggles {
*/
appPlatformGrpcClientAuth?: boolean;
/**
* Enable the app sidecar feature that allows rendering 2 apps at the same time
*/
appSidecar?: boolean;
/**
* Enable the groupsync extension for managing Group Attribute Sync feature
*/
groupAttributeSync?: boolean;

View File

@ -1,49 +0,0 @@
import { createContext, useContext } from 'react';
import { useObservable } from 'react-use';
import { SidecarService_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
export const SidecarContext_EXPERIMENTAL = createContext<SidecarService_EXPERIMENTAL>(
sidecarServiceSingleton_EXPERIMENTAL
);
/**
* This is the main way to interact with the sidecar service inside a react context. It provides a wrapper around the
* service props so that even though they are observables we just pass actual values to the components.
*
* @experimental
*/
export function useSidecar_EXPERIMENTAL() {
// As the sidecar service functionality is behind feature flag this does not need to be for now
const service = useContext(SidecarContext_EXPERIMENTAL);
if (!service) {
throw new Error('No SidecarContext found');
}
const initialContext = useObservable(service.initialContextObservable, service.initialContext);
const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId);
const locationService = service.getLocationService();
return {
activePluginId,
initialContext,
locationService,
// TODO: currently this allows anybody to open any app, in the future we should probably scope this to the
// current app but that means we will need to incorporate this better into the plugin platform APIs which
// we will do once the functionality is reasonably stable
openApp: (pluginId: string, context?: unknown) => {
return service.openApp(pluginId, context);
},
openAppV2: (pluginId: string, path?: string) => {
return service.openAppV2(pluginId, path);
},
openAppV3: (options: { pluginId: string; path?: string; follow?: boolean }) => {
return service.openAppV3(options);
},
closeApp: () => service.closeApp(),
isAppOpened: (pluginId: string) => {
return service.isAppOpened(pluginId);
},
};
}

View File

@ -1,152 +0,0 @@
import * as H from 'history';
import { config } from '../config';
import { HistoryWrapper } from './LocationService';
import { SidecarService_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
function setup() {
const mainLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/explore'] }));
const sidecarService = new SidecarService_EXPERIMENTAL(mainLocationService);
return {
mainLocationService,
sidecarService,
};
}
describe('SidecarService_EXPERIMENTAL', () => {
beforeAll(() => {
config.featureToggles.appSidecar = true;
});
afterAll(() => {
config.featureToggles.appSidecar = false;
});
it('has the correct state after opening and closing an app', () => {
const { sidecarService } = setup();
sidecarService.openApp('pluginId', { filter: 'test' });
expect(sidecarService.activePluginId).toBe('pluginId');
expect(sidecarService.initialContext).toMatchObject({ filter: 'test' });
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId');
sidecarService.closeApp();
expect(sidecarService.activePluginId).toBe(undefined);
expect(sidecarService.initialContext).toBe(undefined);
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
});
it('has the correct state after opening and closing an app v2', () => {
const { sidecarService } = setup();
sidecarService.openAppV2('pluginId', '/test');
expect(sidecarService.activePluginId).toBe('pluginId');
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId/test');
sidecarService.closeApp();
expect(sidecarService.activePluginId).toBe(undefined);
expect(sidecarService.initialContext).toBe(undefined);
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
});
it('has the correct state after opening and closing an app v3', () => {
const { sidecarService } = setup();
sidecarService.openAppV3({ pluginId: 'pluginId', path: '/test' });
expect(sidecarService.activePluginId).toBe('pluginId');
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId/test');
sidecarService.closeApp();
expect(sidecarService.activePluginId).toBe(undefined);
expect(sidecarService.initialContext).toBe(undefined);
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
});
it('reports correct opened state', () => {
const { sidecarService } = setup();
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
sidecarService.openApp('pluginId');
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
sidecarService.closeApp();
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
it('reports correct opened state v2', () => {
const { sidecarService } = setup();
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
sidecarService.openAppV2('pluginId');
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
sidecarService.closeApp();
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
it('reports correct opened state v3', () => {
const { sidecarService } = setup();
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
sidecarService.openAppV3({ pluginId: 'pluginId' });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
sidecarService.closeApp();
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
it('autocloses on not allowed routes', () => {
const { sidecarService, mainLocationService } = setup();
sidecarService.openAppV3({ pluginId: 'pluginId' });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
mainLocationService.push('/config');
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
it('autocloses on when changing route', () => {
const { sidecarService, mainLocationService } = setup();
sidecarService.openAppV3({ pluginId: 'pluginId' });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
mainLocationService.push('/a/other-app');
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
it('does not autocloses when set to follow', () => {
const { sidecarService, mainLocationService } = setup();
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
mainLocationService.push('/a/other-app');
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
});
it('autocloses on not allowed routes when set to follow', () => {
const { sidecarService, mainLocationService } = setup();
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
mainLocationService.push('/config');
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
it('autocloses on not allowed routes when set to follow', () => {
const { sidecarService, mainLocationService } = setup();
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
mainLocationService.push('/config');
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
it('opens sidecar even if starting route is not allowed and then it changes', () => {
const mainLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/login'] }));
const sidecarService = new SidecarService_EXPERIMENTAL(mainLocationService);
mainLocationService.push('/explore');
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
});
});

View File

@ -1,342 +0,0 @@
import * as H from 'history';
import { pick } from 'lodash';
import { BehaviorSubject, map, Observable } from 'rxjs';
import { reportInteraction } from '../analytics/utils';
import { config } from '../config';
import { HistoryWrapper, locationService as mainLocationService, LocationService } from './LocationService';
// Only allow sidecar to be opened on these routes. It does not seem to make sense to keep the sidecar opened on
// config/admin pages for example.
// At this moment let's be restrictive about where the sidecar can show and add more routes if there is a need.
const ALLOW_ROUTES = [
/(^\/d\/)/, // dashboards
/^\/explore/, // explore + explore metrics
/^\/a\/[^\/]+/, // app plugins
/^\/alerting/,
];
/**
* This is a service that handles state and operation of a sidecar feature (sideview to render a second app in grafana).
* At this moment this is highly experimental and if used should be understood to break easily with newer versions.
* None of this functionality works without a feature toggle `appSidecar` being enabled.
*
* Right now this being in a single service is more of a practical tradeoff for easier isolation in the future these
* APIs may be integrated into other services or features like app extensions, plugin system etc.
*
* @experimental
*/
export class SidecarService_EXPERIMENTAL {
private _initialContext: BehaviorSubject<unknown | undefined>;
private sidecarLocationService: LocationService;
private mainLocationService: LocationService;
// If true we don't close the sidecar when user navigates to another app or part of Grafana from where the sidecar
// was opened.
private follow = false;
// Keep track of where the sidecar was originally opened for autoclose behaviour.
private mainLocationWhenOpened: string | undefined;
private mainOnAllowedRoute = false;
constructor(mainLocationService: LocationService) {
this._initialContext = new BehaviorSubject<unknown | undefined>(undefined);
this.mainLocationService = mainLocationService;
this.sidecarLocationService = new HistoryWrapper(
createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' })
);
this.handleMainLocationChanges();
}
private assertFeatureEnabled() {
if (!config.featureToggles.appSidecar) {
console.warn('The `appSidecar` feature toggle is not enabled, doing nothing.');
return false;
}
return true;
}
private updateMainLocationWhenOpened() {
const pathname = this.mainLocationService.getLocation().pathname;
for (const route of ALLOW_ROUTES) {
const match = pathname.match(route)?.[0];
if (match) {
this.mainLocationWhenOpened = match;
return;
}
}
}
/**
* Every time the main location changes we check if we should keep the sidecar open or close it based on list
* of allowed routes and also based on the follow flag when opening the app.
*/
private handleMainLocationChanges() {
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) =>
this.mainLocationService.getLocation().pathname.match(prefix)
);
this.mainLocationService.getLocationObservable().subscribe((location) => {
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) => location.pathname.match(prefix));
if (!this.activePluginId) {
return;
}
if (!this.mainOnAllowedRoute) {
this.closeApp();
return;
}
// We check if we moved to some other app or part of grafana from where we opened the sidecar.
const isTheSameLocation = Boolean(
this.mainLocationWhenOpened && location.pathname.startsWith(this.mainLocationWhenOpened)
);
if (!(isTheSameLocation || this.follow)) {
this.closeApp();
}
});
}
/**
* Get current app id of the app in sidecar. This is most probably provisional. In the future
* this should be driven by URL addressing so that routing for the apps don't change. Useful just internally
* to decide which app to render.
*
* @experimental
*/
get activePluginIdObservable() {
return this.sidecarLocationService.getLocationObservable().pipe(
map((val) => {
return getPluginIdFromUrl(val?.pathname || '');
})
);
}
/**
* Get initial context which is whatever data was passed when calling the 'openApp' function. This is meant as
* a way for the app to initialize it's state based on some context that is passed to it from the primary app.
*
* @experimental
*/
get initialContextObservable() {
return this._initialContext.asObservable();
}
// Get the current value of the subject, this is needed if we want the value immediately. For example if used in
// hook in react with useObservable first render would return undefined even if the behaviourSubject has some
// value which will be emitted in the next tick and thus next rerender.
get initialContext() {
return this._initialContext.getValue();
}
/**
* @experimental
*/
get activePluginId() {
return getPluginIdFromUrl(this.sidecarLocationService.getLocation().pathname);
}
getLocationService() {
return this.sidecarLocationService;
}
/**
* Opens an app in a sidecar. You can also pass some context object that will be then available to the app.
* @deprecated
* @experimental
*/
openApp(pluginId: string, context?: unknown) {
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
return;
}
this._initialContext.next(context);
this.openAppV3({ pluginId, follow: false });
}
/**
* Opens an app in a sidecar. You can also relative path inside the app to open.
* @deprecated
* @experimental
*/
openAppV2(pluginId: string, path?: string) {
this.openAppV3({ pluginId, path, follow: false });
}
/**
* Opens an app in a sidecar. You can also relative path inside the app to open.
* @param options.pluginId Plugin ID of the app to open
* @param options.path Relative path inside the app to open
* @param options.follow If true, the sidecar will stay open even if the main location change to another app or
* Grafana section
*
* @experimental
*/
openAppV3(options: { pluginId: string; path?: string; follow?: boolean }) {
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
return;
}
this.follow = options.follow || false;
this.updateMainLocationWhenOpened();
this.sidecarLocationService.push({ pathname: `/a/${options.pluginId}${options.path || ''}` });
reportInteraction('sidecar_service_open_app', { pluginId: options.pluginId, follow: options.follow });
}
/**
* @experimental
*/
closeApp() {
if (!this.assertFeatureEnabled()) {
return;
}
this.follow = false;
this.mainLocationWhenOpened = undefined;
this._initialContext.next(undefined);
this.sidecarLocationService.replace({ pathname: '/' });
reportInteraction('sidecar_service_close_app');
}
/**
* This is mainly useful inside an app extensions which are executed outside the main app context but can work
* differently depending on whether their app is currently rendered or not.
*
* This is also true only in case a sidecar is opened. In other cases, just to check if a single app is opened
* probably does not make sense.
*
* This means these are the states and the result of this function:
* Single app is opened: false (may seem strange from considering the function name, but the main point of
* this is to recognize when the app needs to do specific alteration in context of running next to second app)
* 2 apps are opened and pluginId is the one in the main window: true
* 2 apps are opened and pluginId is the one in the sidecar window: true
* 2 apps are opened and pluginId is not one of those: false
*
* @experimental
*/
isAppOpened(pluginId: string) {
if (!this.assertFeatureEnabled()) {
return false;
}
const result = !!(this.activePluginId && (this.activePluginId === pluginId || getMainAppPluginId() === pluginId));
reportInteraction('sidecar_service_is_app_opened', { pluginId, isOpened: result });
return result;
}
}
const pluginIdUrlRegex = /a\/([^\/]+)/;
function getPluginIdFromUrl(url: string) {
return url.match(pluginIdUrlRegex)?.[1];
}
// The app plugin that is "open" in the main Grafana view
function getMainAppPluginId() {
// TODO: not great but we have to get a handle on the other locationService used for the main view and easiest way
// right now is through this global singleton
const { pathname } = mainLocationService.getLocation();
// A naive way to sort of simulate core features being an app and having an appID
let mainApp = getPluginIdFromUrl(pathname);
if (!mainApp && pathname.match(/\/explore/)) {
mainApp = 'explore';
}
if (!mainApp && pathname.match(/\/d\//)) {
mainApp = 'dashboards';
}
return mainApp || 'unknown';
}
type LocalStorageHistoryOptions = {
storageKey: string;
};
interface LocationStorageHistory extends H.MemoryHistory {
getLocationObservable(): Observable<H.Location | undefined>;
}
/**
* Simple wrapper over the memory history that persists the location in the localStorage.
*
* @param options
*/
function createLocationStorageHistory(options: LocalStorageHistoryOptions): LocationStorageHistory {
const storedLocation = localStorage.getItem(options.storageKey);
const initialEntry = storedLocation ? JSON.parse(storedLocation) : '/';
const locationSubject = new BehaviorSubject<H.Location | undefined>(initialEntry);
const memoryHistory = H.createMemoryHistory({ initialEntries: [initialEntry] });
let currentLocation = memoryHistory.location;
function maybeUpdateLocation() {
if (memoryHistory.location !== currentLocation) {
localStorage.setItem(
options.storageKey,
JSON.stringify(pick(memoryHistory.location, 'pathname', 'search', 'hash'))
);
currentLocation = memoryHistory.location;
locationSubject.next(memoryHistory.location);
}
}
// This creates a sort of proxy over the memory location just to add the localStorage persistence and the location
// observer. We could achieve the same effect by a listener but that would create a memory leak as there would be no
// reasonable way to unsubcribe the listener later on.
// Another issue is that react router for some reason does not care about proper `this` binding and just calls these
// as normal functions. So if this were to be a class we would still need to bind each of these methods to the
// instance so at that moment this just seems easier.
return {
...memoryHistory,
// Getter aren't destructured as getter but as values, so they have to be still here even though we are not
// modifying them.
get index() {
return memoryHistory.index;
},
get entries() {
return memoryHistory.entries;
},
get length() {
return memoryHistory.length;
},
get action() {
return memoryHistory.action;
},
get location() {
return memoryHistory.location;
},
push(location: H.Path | H.LocationDescriptor<H.LocationState>, state?: H.LocationState) {
memoryHistory.push(location, state);
maybeUpdateLocation();
},
replace(location: H.Path | H.LocationDescriptor<H.LocationState>, state?: H.LocationState) {
memoryHistory.replace(location, state);
maybeUpdateLocation();
},
go(n: number) {
memoryHistory.go(n);
maybeUpdateLocation();
},
goBack() {
memoryHistory.goBack();
maybeUpdateLocation();
},
goForward() {
memoryHistory.goForward();
maybeUpdateLocation();
},
getLocationObservable() {
return locationSubject.asObservable();
},
};
}
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL(mainLocationService);

View File

@ -6,8 +6,6 @@ export * from './templateSrv';
export * from './live';
export * from './LocationService';
export * from './appEvents';
export * from './SidecarService_EXPERIMENTAL';
export * from './SidecarContext_EXPERIMENTAL';
export {
setPluginComponentHook,

View File

@ -1244,12 +1244,6 @@ var (
HideFromDocs: true,
HideFromAdminPage: true,
},
{
Name: "appSidecar",
Description: "Enable the app sidecar feature that allows rendering 2 apps at the same time",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
},
{
Name: "groupAttributeSync",
Description: "Enable the groupsync extension for managing Group Attribute Sync feature",

View File

@ -355,7 +355,6 @@ exploreLogsLimitedTimeRange,2024-08-29T13:55:59Z,,15a4ff992bd925f5714bc4e73fa976
exploreLogsShardSplitting,2024-08-29T13:55:59Z,,15a4ff992bd925f5714bc4e73fa9766a995b5711,Sven Grossmann
newFiltersUI,2024-08-30T12:48:13Z,,00ae49a61adff11e5fbac3341941b35185d9e8d9,Sergej-Vlasov
appPlatformAccessTokens,2024-09-05T16:18:44Z,2024-10-14T10:47:18Z,d5ebaa0ef92edecfb9511dc7c8080be25f8cc7e7,Claudiu Dragalina-Paraipan
appSidecar,2024-09-09T12:45:05Z,,5e2ac24890906e5070323d87730dd78a4f885963,Andrej Ocenas
vizActions,2024-09-09T14:11:55Z,,af48d3db1eb2d8681843f5997e50fea5e5ea3096,Adela Almasan
groupAttributeSync,2024-09-09T15:29:43Z,,6ded6a8872204a818b3795dc733cc5fe5db066a0,Aaron Godin
alertingFilterV2,2024-09-11T11:29:26Z,,90ee52e8d9c14237f8a57b622c0def7512e657cd,Gilles De Mey

1 #name created deleted hash author
355 exploreLogsShardSplitting 2024-08-29T13:55:59Z 15a4ff992bd925f5714bc4e73fa9766a995b5711 Sven Grossmann
356 newFiltersUI 2024-08-30T12:48:13Z 00ae49a61adff11e5fbac3341941b35185d9e8d9 Sergej-Vlasov
357 appPlatformAccessTokens 2024-09-05T16:18:44Z 2024-10-14T10:47:18Z d5ebaa0ef92edecfb9511dc7c8080be25f8cc7e7 Claudiu Dragalina-Paraipan
appSidecar 2024-09-09T12:45:05Z 5e2ac24890906e5070323d87730dd78a4f885963 Andrej Ocenas
358 vizActions 2024-09-09T14:11:55Z af48d3db1eb2d8681843f5997e50fea5e5ea3096 Adela Almasan
359 groupAttributeSync 2024-09-09T15:29:43Z 6ded6a8872204a818b3795dc733cc5fe5db066a0 Aaron Godin
360 alertingFilterV2 2024-09-11T11:29:26Z 90ee52e8d9c14237f8a57b622c0def7512e657cd Gilles De Mey

View File

@ -161,7 +161,6 @@ exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,fals
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
homeSetupGuide,experimental,@grafana/growth-and-onboarding,false,false,true
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
appSidecar,experimental,@grafana/grafana-frontend-platform,false,false,false
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
improvedExternalSessionHandling,preview,@grafana/identity-access-team,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
161 exploreLogsLimitedTimeRange experimental @grafana/observability-logs false false true
162 homeSetupGuide experimental @grafana/growth-and-onboarding false false true
163 appPlatformGrpcClientAuth experimental @grafana/identity-access-team false false false
appSidecar experimental @grafana/grafana-frontend-platform false false false
164 groupAttributeSync privatePreview @grafana/identity-access-team false false false
165 alertingQueryAndExpressionsStepMode GA @grafana/alerting-squad false false true
166 improvedExternalSessionHandling preview @grafana/identity-access-team false false false

View File

@ -655,10 +655,6 @@ const (
// Enables the gRPC client to authenticate with the App Platform by using ID &amp; access tokens
FlagAppPlatformGrpcClientAuth = "appPlatformGrpcClientAuth"
// FlagAppSidecar
// Enable the app sidecar feature that allows rendering 2 apps at the same time
FlagAppSidecar = "appSidecar"
// FlagGroupAttributeSync
// Enable the groupsync extension for managing Group Attribute Sync feature
FlagGroupAttributeSync = "groupAttributeSync"

View File

@ -479,18 +479,6 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "appSidecar",
"resourceVersion": "1743693517832",
"creationTimestamp": "2024-09-09T12:45:05Z"
},
"spec": {
"description": "Enable the app sidecar feature that allows rendering 2 apps at the same time",
"stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform"
}
},
{
"metadata": {
"name": "assetSriChecks",

View File

@ -4,13 +4,7 @@ import CacheProvider from 'react-inlinesvg/provider';
import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom-v5-compat';
import {
config,
navigationLogger,
reportInteraction,
SidecarContext_EXPERIMENTAL,
sidecarServiceSingleton_EXPERIMENTAL,
} from '@grafana/runtime';
import { config, navigationLogger, reportInteraction } from '@grafana/runtime';
import { ErrorBoundaryAlert, PortalContainer, TimeRangeProvider } from '@grafana/ui';
import { getAppRoutes } from 'app/routes/routes';
import { store } from 'app/store/store';
@ -26,7 +20,7 @@ import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { ScopesContextProvider } from './features/scopes/ScopesContextProvider';
import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper';
import { RouterWrapper } from './routes/RoutesWrapper';
interface AppWrapperProps {
app: GrafanaApp;
@ -125,24 +119,18 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
<MaybeTimeRangeProvider>
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
<ScopesContextProvider>
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
<MaybeExtensionSidebarProvider>
<GlobalStylesWrapper />
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
) : (
<RouterWrapper {...routerWrapperProps} />
)}
<LiveConnectionWarning />
<PortalContainer />
</div>
</MaybeExtensionSidebarProvider>
</ExtensionRegistriesProvider>
</ScopesContextProvider>
</SidecarContext_EXPERIMENTAL.Provider>
<ScopesContextProvider>
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
<MaybeExtensionSidebarProvider>
<GlobalStylesWrapper />
<div className="grafana-app">
<RouterWrapper {...routerWrapperProps} />
<LiveConnectionWarning />
<PortalContainer />
</div>
</MaybeExtensionSidebarProvider>
</ExtensionRegistriesProvider>
</ScopesContextProvider>
</MaybeTimeRangeProvider>
</KBarProvider>
</CacheProvider>

View File

@ -1,22 +1,13 @@
import { css } from '@emotion/css';
import { ComponentType, ReactNode } from 'react';
// eslint-disable-next-line no-restricted-imports
import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { GrafanaTheme2 } from '@grafana/data';
import {
locationService,
LocationServiceProvider,
useChromeHeaderHeight,
useSidecar_EXPERIMENTAL,
} from '@grafana/runtime';
import { GlobalStyles, IconButton, ModalRoot, Stack, useSplitter, useStyles2 } from '@grafana/ui';
import { locationService, LocationServiceProvider } from '@grafana/runtime';
import { ModalRoot, Stack } from '@grafana/ui';
import { AppChrome } from '../core/components/AppChrome/AppChrome';
import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList';
import { ModalsContextProvider } from '../core/context/ModalsContextProvider';
import { t } from '../core/internationalization';
import { QueriesDrawerContextProvider } from '../features/explore/QueriesDrawer/QueriesDrawerContext';
function ExtraProviders(props: { children: ReactNode; providers: Array<ComponentType<{ children: ReactNode }>> }) {
@ -60,117 +51,3 @@ export function RouterWrapper(props: RouterWrapperProps) {
</Router>
);
}
/**
* Renders both the main app tree and a secondary sidecar app tree to show 2 apps at the same time in a resizable split
* view.
* @param props
* @constructor
*/
export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) {
const { closeApp, locationService, activePluginId } = useSidecar_EXPERIMENTAL();
let { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
direction: 'row',
initialSize: 0.6,
dragPosition: 'end',
handleSize: 'sm',
});
// The style changes allow the resizing to be more flexible and not constrained by the content dimensions. In the
// future this could be a switch in the useSplitter but for now it's here until this feature is more final.
function alterStyles<T extends { style: React.CSSProperties }>(props: T): T {
return {
...props,
style: { ...props.style, overflow: 'auto', minWidth: 'unset', minHeight: 'unset' },
};
}
primaryProps = alterStyles(primaryProps);
secondaryProps = alterStyles(secondaryProps);
const headerHeight = useChromeHeaderHeight();
const styles = useStyles2(getStyles, headerHeight);
// Right now we consider only app plugin to be opened here but in the future we might want to just open any kind
// of url and so this should check whether there is a location in the sidecar locationService.
const sidecarOpen = Boolean(activePluginId);
return (
// Why do we need these 2 wrappers here? We want for one app case to render very similar as if there was no split
// wrapper at all but the split wrapper needs to have these wrappers to attach its container props to. At the same
// time we don't want to rerender the main app when going from 2 apps render to single app render which would happen
// if we removed the wrappers. So the solution is to keep those 2 divs but make them no actually do anything in
// case we are rendering a single app.
<div {...(sidecarOpen ? containerProps : { className: styles.dummyWrapper })}>
<div {...(sidecarOpen ? primaryProps : { className: styles.dummyWrapper })}>
<RouterWrapper {...props} />
</div>
{/* Sidecar */}
{sidecarOpen && (
<>
<div {...splitterProps} />
<div {...secondaryProps}>
<Router history={locationService.getHistory()}>
<LocationServiceProvider service={locationService}>
<CompatRouter>
<GlobalStyles />
<div className={styles.secondAppChrome}>
<div className={styles.secondAppToolbar}>
<IconButton
size={'lg'}
style={{ margin: '8px' }}
name={'times'}
aria-label={t('routes-wrapper.close-aria-label', 'Close')}
onClick={() => closeApp()}
/>
</div>
<div className={styles.secondAppWrapper}>
{/*We don't render anything other than app plugin but we want to keep the same routing layout so*/}
{/*there are is no difference with matching relative routes between main and sidecar view.*/}
{props.routes}
</div>
</div>
</CompatRouter>
</LocationServiceProvider>
</Router>
</div>
</>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2, headerHeight: number | undefined) => {
return {
secondAppChrome: css({
label: 'secondAppChrome',
display: 'flex',
height: '100%',
width: '100%',
paddingTop: headerHeight || 0,
flexGrow: 1,
flexDirection: 'column',
}),
secondAppToolbar: css({
label: 'secondAppToolbar',
display: 'flex',
justifyContent: 'flex-end',
}),
secondAppWrapper: css({
label: 'secondAppWrapper',
overflow: 'auto',
flex: '1',
}),
// This is basically the same as grafana-app class. This means the 2 additional wrapper divs that are in between
// grafana-app div and the main app component don't actually change anything in the layout.
dummyWrapper: css({
label: 'dummyWrapper',
display: 'flex',
height: '100vh',
flexDirection: 'column',
}),
};
};

View File

@ -7135,9 +7135,6 @@
"reload-button": "Reload",
"title": "Unable to find application file"
},
"routes-wrapper": {
"close-aria-label": "Close"
},
"sandbox": {
"test-stuff-page": {
"application-notifications-toasts-testing": "Application notifications (toasts) testing",