mirror of https://github.com/grafana/grafana.git
Sidecar: Remove experimental Sidecar implementation (#103786)
* Sidecar: remove `appSidecar` feature toggle * Sidecar: Remove sidecar implementation * lint
This commit is contained in:
parent
0bad0526f5
commit
05338838b0
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -655,10 +655,6 @@ const (
|
|||
// Enables the gRPC client to authenticate with the App Platform by using ID & 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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue