Sidecar: Specify where it can be opened and when it automatically closes (#96683)

This commit is contained in:
Andrej Ocenas 2024-11-21 11:27:01 +01:00 committed by GitHub
parent fd3ecacd05
commit 58e9f22c1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 188 additions and 43 deletions

View File

@ -1,5 +1,6 @@
import * as H from 'history'; import * as H from 'history';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { BehaviorSubject, Observable } from 'rxjs';
import { deprecationWarning, UrlQueryMap, urlUtil } from '@grafana/data'; import { deprecationWarning, UrlQueryMap, urlUtil } from '@grafana/data';
import { attachDebugger, createLogger } from '@grafana/ui'; import { attachDebugger, createLogger } from '@grafana/ui';
@ -21,6 +22,7 @@ export interface LocationService {
getHistory: () => H.History; getHistory: () => H.History;
getSearch: () => URLSearchParams; getSearch: () => URLSearchParams;
getSearchObject: () => UrlQueryMap; getSearchObject: () => UrlQueryMap;
getLocationObservable: () => Observable<H.Location>;
/** /**
* This is from the old LocationSrv interface * This is from the old LocationSrv interface
@ -31,6 +33,7 @@ export interface LocationService {
/** @internal */ /** @internal */
export class HistoryWrapper implements LocationService { export class HistoryWrapper implements LocationService {
private readonly history: H.History; private readonly history: H.History;
private locationObservable: BehaviorSubject<H.Location>;
constructor(history?: H.History) { constructor(history?: H.History) {
// If no history passed create an in memory one if being called from test // If no history passed create an in memory one if being called from test
@ -40,6 +43,12 @@ export class HistoryWrapper implements LocationService {
? H.createMemoryHistory({ initialEntries: ['/'] }) ? H.createMemoryHistory({ initialEntries: ['/'] })
: H.createBrowserHistory({ basename: config.appSubUrl ?? '/' })); : H.createBrowserHistory({ basename: config.appSubUrl ?? '/' }));
this.locationObservable = new BehaviorSubject(this.history.location);
this.history.listen((location) => {
this.locationObservable.next(location);
});
this.partial = this.partial.bind(this); this.partial = this.partial.bind(this);
this.push = this.push.bind(this); this.push = this.push.bind(this);
this.replace = this.replace.bind(this); this.replace = this.replace.bind(this);
@ -48,6 +57,10 @@ export class HistoryWrapper implements LocationService {
this.getLocation = this.getLocation.bind(this); this.getLocation = this.getLocation.bind(this);
} }
getLocationObservable() {
return this.locationObservable.asObservable();
}
getHistory() { getHistory() {
return this.history; return this.history;
} }

View File

@ -1,15 +1,12 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { useObservable } from 'react-use'; import { useObservable } from 'react-use';
import { locationService as mainLocationService } from './LocationService';
import { SidecarService_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL'; import { SidecarService_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
export const SidecarContext_EXPERIMENTAL = createContext<SidecarService_EXPERIMENTAL>( export const SidecarContext_EXPERIMENTAL = createContext<SidecarService_EXPERIMENTAL>(
sidecarServiceSingleton_EXPERIMENTAL sidecarServiceSingleton_EXPERIMENTAL
); );
const HIDDEN_ROUTES = ['/login'];
/** /**
* This is the main way to interact with the sidecar service inside a react context. It provides a wrapper around the * 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. * service props so that even though they are observables we just pass actual values to the components.
@ -27,32 +24,25 @@ export function useSidecar_EXPERIMENTAL() {
const initialContext = useObservable(service.initialContextObservable, service.initialContext); const initialContext = useObservable(service.initialContextObservable, service.initialContext);
const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId); const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId);
const locationService = service.getLocationService(); const locationService = service.getLocationService();
const forceHidden = HIDDEN_ROUTES.includes(mainLocationService.getLocation().pathname);
return { return {
activePluginId: forceHidden ? undefined : activePluginId, activePluginId,
initialContext: forceHidden ? undefined : initialContext, initialContext,
locationService, locationService,
// TODO: currently this allows anybody to open any app, in the future we should probably scope this to the // 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 // 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 // we will do once the functionality is reasonably stable
openApp: (pluginId: string, context?: unknown) => { openApp: (pluginId: string, context?: unknown) => {
if (forceHidden) {
return;
}
return service.openApp(pluginId, context); return service.openApp(pluginId, context);
}, },
openAppV2: (pluginId: string, path?: string) => { openAppV2: (pluginId: string, path?: string) => {
if (forceHidden) {
return;
}
return service.openAppV2(pluginId, path); return service.openAppV2(pluginId, path);
}, },
openAppV3: (options: { pluginId: string; path?: string; follow?: boolean }) => {
return service.openAppV3(options);
},
closeApp: () => service.closeApp(), closeApp: () => service.closeApp(),
isAppOpened: (pluginId: string) => { isAppOpened: (pluginId: string) => {
if (forceHidden) {
return false;
}
return service.isAppOpened(pluginId); return service.isAppOpened(pluginId);
}, },
}; };

View File

@ -1,17 +1,28 @@
import * as H from 'history';
import { config } from '../config'; import { config } from '../config';
import { HistoryWrapper } from './LocationService';
import { SidecarService_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL'; import { SidecarService_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
describe('SidecarService_EXPERIMENTAL', () => { describe('SidecarService_EXPERIMENTAL', () => {
beforeEach(() => { let mainLocationService: HistoryWrapper;
let sidecarService: SidecarService_EXPERIMENTAL;
beforeAll(() => {
config.featureToggles.appSidecar = true; config.featureToggles.appSidecar = true;
}); });
afterEach(() => {
config.featureToggles.appSidecar = undefined; afterAll(() => {
config.featureToggles.appSidecar = false;
});
beforeEach(() => {
mainLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/explore'] }));
sidecarService = new SidecarService_EXPERIMENTAL(mainLocationService);
}); });
it('has the correct state after opening and closing an app', () => { it('has the correct state after opening and closing an app', () => {
const sidecarService = new SidecarService_EXPERIMENTAL();
sidecarService.openApp('pluginId', { filter: 'test' }); sidecarService.openApp('pluginId', { filter: 'test' });
expect(sidecarService.activePluginId).toBe('pluginId'); expect(sidecarService.activePluginId).toBe('pluginId');
@ -25,7 +36,6 @@ describe('SidecarService_EXPERIMENTAL', () => {
}); });
it('has the correct state after opening and closing an app v2', () => { it('has the correct state after opening and closing an app v2', () => {
const sidecarService = new SidecarService_EXPERIMENTAL();
sidecarService.openAppV2('pluginId', '/test'); sidecarService.openAppV2('pluginId', '/test');
expect(sidecarService.activePluginId).toBe('pluginId'); expect(sidecarService.activePluginId).toBe('pluginId');
@ -37,8 +47,19 @@ describe('SidecarService_EXPERIMENTAL', () => {
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/'); expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
}); });
it('has the correct state after opening and closing an app v3', () => {
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', () => { it('reports correct opened state', () => {
const sidecarService = new SidecarService_EXPERIMENTAL();
expect(sidecarService.isAppOpened('pluginId')).toBe(false); expect(sidecarService.isAppOpened('pluginId')).toBe(false);
sidecarService.openApp('pluginId'); sidecarService.openApp('pluginId');
@ -49,7 +70,6 @@ describe('SidecarService_EXPERIMENTAL', () => {
}); });
it('reports correct opened state v2', () => { it('reports correct opened state v2', () => {
const sidecarService = new SidecarService_EXPERIMENTAL();
expect(sidecarService.isAppOpened('pluginId')).toBe(false); expect(sidecarService.isAppOpened('pluginId')).toBe(false);
sidecarService.openAppV2('pluginId'); sidecarService.openAppV2('pluginId');
@ -58,4 +78,46 @@ describe('SidecarService_EXPERIMENTAL', () => {
sidecarService.closeApp(); sidecarService.closeApp();
expect(sidecarService.isAppOpened('pluginId')).toBe(false); expect(sidecarService.isAppOpened('pluginId')).toBe(false);
}); });
it('reports correct opened state v3', () => {
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', () => {
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', () => {
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', () => {
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', () => {
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
mainLocationService.push('/config');
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
});
}); });

View File

@ -5,7 +5,17 @@ import { BehaviorSubject, map, Observable } from 'rxjs';
import { reportInteraction } from '../analytics/utils'; import { reportInteraction } from '../analytics/utils';
import { config } from '../config'; import { config } from '../config';
import { HistoryWrapper, LocationService, locationService } from './LocationService'; 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). * This is a service that handles state and operation of a sidecar feature (sideview to render a second app in grafana).
@ -19,15 +29,26 @@ import { HistoryWrapper, LocationService, locationService } from './LocationServ
*/ */
export class SidecarService_EXPERIMENTAL { export class SidecarService_EXPERIMENTAL {
private _initialContext: BehaviorSubject<unknown | undefined>; private _initialContext: BehaviorSubject<unknown | undefined>;
private memoryLocationService: LocationService;
private history: LocationStorageHistory;
constructor() { 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._initialContext = new BehaviorSubject<unknown | undefined>(undefined);
// We need a local ref for this so we can tap into the location changes and drive rerendering of components based this.mainLocationService = mainLocationService;
// on it without having a parent Router. this.sidecarLocationService = new HistoryWrapper(
this.history = createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' }); createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' })
this.memoryLocationService = new HistoryWrapper(this.history); );
this.handleMainLocationChanges();
} }
private assertFeatureEnabled() { private assertFeatureEnabled() {
@ -39,6 +60,48 @@ export class SidecarService_EXPERIMENTAL {
return true; 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) => {
if (!this.activePluginId) {
return;
}
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) => location.pathname.match(prefix));
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 * 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 * this should be driven by URL addressing so that routing for the apps don't change. Useful just internally
@ -47,7 +110,7 @@ export class SidecarService_EXPERIMENTAL {
* @experimental * @experimental
*/ */
get activePluginIdObservable() { get activePluginIdObservable() {
return this.history.getLocationObservable().pipe( return this.sidecarLocationService.getLocationObservable().pipe(
map((val) => { map((val) => {
return getPluginIdFromUrl(val?.pathname || ''); return getPluginIdFromUrl(val?.pathname || '');
}) })
@ -75,11 +138,11 @@ export class SidecarService_EXPERIMENTAL {
* @experimental * @experimental
*/ */
get activePluginId() { get activePluginId() {
return getPluginIdFromUrl(this.memoryLocationService.getLocation().pathname); return getPluginIdFromUrl(this.sidecarLocationService.getLocation().pathname);
} }
getLocationService() { getLocationService() {
return this.memoryLocationService; return this.sidecarLocationService;
} }
/** /**
@ -88,26 +151,41 @@ export class SidecarService_EXPERIMENTAL {
* @experimental * @experimental
*/ */
openApp(pluginId: string, context?: unknown) { openApp(pluginId: string, context?: unknown) {
if (!this.assertFeatureEnabled()) { if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
return; return;
} }
this._initialContext.next(context); this._initialContext.next(context);
this.memoryLocationService.push({ pathname: `/a/${pluginId}` }); this.openAppV3({ pluginId, follow: false });
reportInteraction('sidecar_service_open_app', { pluginId, version: 1 });
} }
/** /**
* Opens an app in a sidecar. You can also relative path inside the app to open. * Opens an app in a sidecar. You can also relative path inside the app to open.
* @deprecated
* @experimental * @experimental
*/ */
openAppV2(pluginId: string, path?: string) { openAppV2(pluginId: string, path?: string) {
if (!this.assertFeatureEnabled()) { 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; return;
} }
this.memoryLocationService.push({ pathname: `/a/${pluginId}${path || ''}` }); this.follow = options.follow || false;
reportInteraction('sidecar_service_open_app', { pluginId, version: 2 });
this.updateMainLocationWhenOpened();
this.sidecarLocationService.push({ pathname: `/a/${options.pluginId}${options.path || ''}` });
reportInteraction('sidecar_service_open_app', { pluginId: options.pluginId, follow: options.follow });
} }
/** /**
@ -118,8 +196,10 @@ export class SidecarService_EXPERIMENTAL {
return; return;
} }
this.follow = false;
this.mainLocationWhenOpened = undefined;
this._initialContext.next(undefined); this._initialContext.next(undefined);
this.memoryLocationService.replace({ pathname: '/' }); this.sidecarLocationService.replace({ pathname: '/' });
reportInteraction('sidecar_service_close_app'); reportInteraction('sidecar_service_close_app');
} }
@ -160,7 +240,7 @@ function getPluginIdFromUrl(url: string) {
function getMainAppPluginId() { function getMainAppPluginId() {
// TODO: not great but we have to get a handle on the other locationService used for the main view and easiest way // 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 // right now is through this global singleton
const { pathname } = locationService.getLocation(); const { pathname } = mainLocationService.getLocation();
// A naive way to sort of simulate core features being an app and having an appID // A naive way to sort of simulate core features being an app and having an appID
let mainApp = getPluginIdFromUrl(pathname); let mainApp = getPluginIdFromUrl(pathname);
@ -258,4 +338,4 @@ function createLocationStorageHistory(options: LocalStorageHistoryOptions): Loca
}; };
} }
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL(); export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL(mainLocationService);