Sidecar: Move service to runtime (#94860)

This commit is contained in:
Andrej Ocenas 2024-10-21 20:26:39 +02:00 committed by GitHub
parent b7e658c5c3
commit c3c1f6ac27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 220 additions and 131 deletions

View File

@ -44,6 +44,7 @@
"@grafana/ui": "11.4.0-pre",
"history": "4.10.1",
"lodash": "4.17.21",
"react-use": "17.5.1",
"rxjs": "7.8.1",
"tslib": "2.7.0"
},

View File

@ -0,0 +1,37 @@
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 activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId);
const initialContext = useObservable(service.initialContextObservable, service.initialContext);
return {
activePluginId,
initialContext,
// 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) => service.openApp(pluginId),
closeApp: (pluginId: string) => service.closeApp(pluginId),
isAppOpened: (pluginId: string) => service.isAppOpened(pluginId),
};
}

View File

@ -0,0 +1,149 @@
import { BehaviorSubject } from 'rxjs';
import { config } from '../config';
import { locationService } from './LocationService';
interface Options {
localStorageKey?: string;
}
/**
* 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 understand 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 _activePluginId: BehaviorSubject<string | undefined>;
private _initialContext: BehaviorSubject<unknown | undefined>;
private localStorageKey: string | undefined;
constructor(options: Options) {
this.localStorageKey = options.localStorageKey;
let initialId = undefined;
if (this.localStorageKey) {
initialId = localStorage.getItem(this.localStorageKey) || undefined;
}
this._activePluginId = new BehaviorSubject<string | undefined>(initialId);
this._initialContext = new BehaviorSubject<unknown | undefined>(undefined);
}
private assertFeatureEnabled() {
if (!config.featureToggles.appSidecar) {
console.warn('The `appSidecar` feature toggle is not enabled, doing nothing.');
return false;
}
return true;
}
/**
* 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._activePluginId.asObservable();
}
/**
* 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 this._activePluginId.getValue();
}
/**
* Opens an app in a sidecar. You can also pass some context object that will be then available to the app.
* @experimental
*/
openApp(pluginId: string, context?: unknown) {
if (!this.assertFeatureEnabled()) {
return;
}
if (this.localStorageKey) {
localStorage.setItem(this.localStorageKey, pluginId);
}
this._activePluginId.next(pluginId);
this._initialContext.next(context);
}
/**
* @experimental
*/
closeApp(pluginId: string) {
if (!this.assertFeatureEnabled()) {
return;
}
if (this._activePluginId.getValue() === pluginId) {
if (this.localStorageKey) {
localStorage.removeItem(this.localStorageKey);
}
this._activePluginId.next(undefined);
this._initialContext.next(undefined);
}
}
/**
* This is mainly useful inside an app extensions which are executed outside of the main app context but can work
* differently depending whether their app is currently rendered or not.
* @experimental
*/
isAppOpened(pluginId: string) {
if (!this.assertFeatureEnabled()) {
return false;
}
if (this._activePluginId.getValue() === pluginId || getMainAppPluginId() === pluginId) {
return true;
}
return false;
}
}
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL({
localStorageKey: 'grafana.sidecar.activePluginId',
});
// The app plugin that is "open" in the main Grafana view
function getMainAppPluginId() {
const { pathname } = locationService.getLocation();
// A naive way to sort of simulate core features being an app and having an appID
let mainApp = pathname.match(/\/a\/([^/]+)/)?.[1];
if (!mainApp && pathname.match(/\/explore/)) {
mainApp = 'explore';
}
if (!mainApp && pathname.match(/\/d\//)) {
mainApp = 'dashboards';
}
return mainApp || 'unknown';
}

View File

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

View File

@ -3,7 +3,13 @@ import { Component, ComponentType } from 'react';
import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { config, navigationLogger, reportInteraction } from '@grafana/runtime';
import {
config,
navigationLogger,
reportInteraction,
SidecarContext_EXPERIMENTAL,
sidecarServiceSingleton_EXPERIMENTAL,
} from '@grafana/runtime';
import { ErrorBoundaryAlert, GlobalStyles, PortalContainer } from '@grafana/ui';
import { getAppRoutes } from 'app/routes/routes';
import { store } from 'app/store/store';
@ -11,10 +17,8 @@ import { store } from 'app/store/store';
import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled';
import { GrafanaApp } from './app';
import { GrafanaContext } from './core/context/GrafanaContext';
import { SidecarContext } from './core/context/SidecarContext';
import { GrafanaRouteWrapper } from './core/navigation/GrafanaRoute';
import { RouteDescriptor } from './core/navigation/types';
import { sidecarService } from './core/services/SidecarService';
import { ThemeProvider } from './core/utils/ConfigProvider';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
@ -96,7 +100,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
<GlobalStyles />
<SidecarContext.Provider value={sidecarService}>
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}>
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
@ -108,7 +112,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
<PortalContainer />
</div>
</ExtensionRegistriesProvider>
</SidecarContext.Provider>
</SidecarContext_EXPERIMENTAL.Provider>
</KBarProvider>
</ThemeProvider>
</GrafanaContext.Provider>

View File

@ -1,22 +0,0 @@
import { createContext, useContext } from 'react';
import { useObservable } from 'react-use';
import { SidecarService, sidecarService } from '../services/SidecarService';
export const SidecarContext = createContext<SidecarService>(sidecarService);
export function useSidecar() {
const activePluginId = useObservable(sidecarService.activePluginId);
const context = useContext(SidecarContext);
if (!context) {
throw new Error('No SidecarContext found');
}
return {
activePluginId,
openApp: (pluginId: string) => context.openApp(pluginId),
closeApp: (pluginId: string) => context.closeApp(pluginId),
isAppOpened: (pluginId: string) => context.isAppOpened(pluginId),
};
}

View File

@ -1,91 +0,0 @@
import { BehaviorSubject } from 'rxjs';
import { config, locationService } from '@grafana/runtime';
interface Options {
localStorageKey?: string;
}
export class SidecarService {
// The ID of the app plugin that is currently opened in the sidecar view
private _activePluginId: BehaviorSubject<string | undefined>;
private localStorageKey: string | undefined;
constructor(options: Options) {
this.localStorageKey = options.localStorageKey;
let initialId = undefined;
if (this.localStorageKey) {
initialId = localStorage.getItem(this.localStorageKey) || undefined;
}
this._activePluginId = new BehaviorSubject<string | undefined>(initialId);
}
private assertFeatureEnabled() {
if (!config.featureToggles.appSidecar) {
console.warn('The `appSidecar` feature toggle is not enabled, doing nothing.');
return false;
}
return true;
}
get activePluginId() {
return this._activePluginId.asObservable();
}
openApp(pluginId: string) {
if (!this.assertFeatureEnabled()) {
return false;
}
if (this.localStorageKey) {
localStorage.setItem(this.localStorageKey, pluginId);
}
return this._activePluginId.next(pluginId);
}
closeApp(pluginId: string) {
if (!this.assertFeatureEnabled()) {
return false;
}
if (this._activePluginId.getValue() === pluginId) {
if (this.localStorageKey) {
localStorage.removeItem(this.localStorageKey);
}
return this._activePluginId.next(undefined);
}
}
isAppOpened(pluginId: string) {
if (!this.assertFeatureEnabled()) {
return false;
}
if (this._activePluginId.getValue() === pluginId || getMainAppPluginId() === pluginId) {
return true;
}
return false;
}
}
export const sidecarService = new SidecarService({ localStorageKey: 'grafana.sidecar.activePluginId' });
// The app plugin that is "open" in the main Grafana view
function getMainAppPluginId() {
const { pathname } = locationService.getLocation();
// A naive way to sort of simulate core features being an app and having an appID
let mainApp = pathname.match(/\/a\/([^/]+)/)?.[1];
if (!mainApp && pathname.match(/\/explore/)) {
mainApp = 'explore';
}
if (!mainApp && pathname.match(/\/d\//)) {
mainApp = 'dashboards';
}
return mainApp || 'unknown';
}

View File

@ -2,8 +2,7 @@ import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { PluginExtension, usePluginContext } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
import { useSidecar } from 'app/core/context/SidecarContext';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult, useSidecar_EXPERIMENTAL } from '@grafana/runtime';
import { getPluginExtensions } from './getPluginExtensions';
import { log } from './logs/log';
@ -19,7 +18,7 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
const pluginContext = usePluginContext();
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
const { activePluginId } = useSidecar();
const { activePluginId } = useSidecar_EXPERIMENTAL();
const { extensionPointId, context, limitPerPlugin } = options;
const { extensions } = useMemo(() => {

View File

@ -20,11 +20,14 @@ import {
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
} from '@grafana/data';
import { reportInteraction, config } from '@grafana/runtime';
import {
reportInteraction,
config,
// TODO: instead of depending on the service as a singleton, inject it as an argument from the React context
sidecarServiceSingleton_EXPERIMENTAL,
} from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
// TODO: instead of depending on the service as a singleton, inject it as an argument from the React context
import { sidecarService } from 'app/core/services/SidecarService';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { ShowModalReactEvent } from 'app/types/events';
@ -388,7 +391,7 @@ export function getLinkExtensionOnClick(
context,
openModal: createOpenModalFunction(pluginId),
isAppOpened: () => isAppOpened(pluginId),
openAppInSideview: () => openAppInSideview(pluginId),
openAppInSideview: (context?: unknown) => openAppInSideview(pluginId, context),
closeAppInSideview: () => closeAppInSideview(pluginId),
};
@ -426,11 +429,12 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
);
}
export const openAppInSideview = (pluginId: string) => sidecarService.openApp(pluginId);
export const openAppInSideview = (pluginId: string, context?: unknown) =>
sidecarServiceSingleton_EXPERIMENTAL.openApp(pluginId, context);
export const closeAppInSideview = (pluginId: string) => sidecarService.closeApp(pluginId);
export const closeAppInSideview = (pluginId: string) => sidecarServiceSingleton_EXPERIMENTAL.closeApp(pluginId);
export const isAppOpened = (pluginId: string) => sidecarService.isAppOpened(pluginId);
export const isAppOpened = (pluginId: string) => sidecarServiceSingleton_EXPERIMENTAL.isAppOpened(pluginId);
// Comes from the `app_mode` setting in the Grafana config (defaults to "development")
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable

View File

@ -4,14 +4,19 @@ import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { GrafanaTheme2 } from '@grafana/data/';
import { HistoryWrapper, locationService, LocationServiceProvider, useChromeHeaderHeight } from '@grafana/runtime';
import {
HistoryWrapper,
locationService,
LocationServiceProvider,
useChromeHeaderHeight,
useSidecar_EXPERIMENTAL,
} from '@grafana/runtime';
import { GlobalStyles, IconButton, ModalRoot, Stack, useSplitter, useStyles2 } from '@grafana/ui';
import { AngularRoot } from '../angular/AngularRoot';
import { AppChrome } from '../core/components/AppChrome/AppChrome';
import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList';
import { ModalsContextProvider } from '../core/context/ModalsContextProvider';
import { useSidecar } from '../core/context/SidecarContext';
import { QueriesDrawerContextProvider } from '../features/explore/QueriesDrawer/QueriesDrawerContext';
import AppRootPage from '../features/plugins/components/AppRootPage';
@ -58,7 +63,7 @@ export function RouterWrapper(props: RouterWrapperProps) {
* @constructor
*/
export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) {
const { activePluginId, closeApp } = useSidecar();
const { activePluginId, closeApp } = useSidecar_EXPERIMENTAL();
let { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
direction: 'row',

View File

@ -4038,6 +4038,7 @@ __metadata:
lodash: "npm:4.17.21"
react: "npm:18.2.0"
react-dom: "npm:18.2.0"
react-use: "npm:17.5.1"
rimraf: "npm:6.0.1"
rollup: "npm:^4.22.4"
rollup-plugin-dts: "npm:^6.1.1"