From c3c1f6ac2729a4f50a55218eeae20375ca71dc4d Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 21 Oct 2024 20:26:39 +0200 Subject: [PATCH] Sidecar: Move service to runtime (#94860) --- packages/grafana-runtime/package.json | 1 + .../services/SidecarContext_EXPERIMENTAL.ts | 37 +++++ .../services/SidecarService_EXPERIMENTAL.ts | 149 ++++++++++++++++++ .../grafana-runtime/src/services/index.ts | 2 + public/app/AppWrapper.tsx | 14 +- public/app/core/context/SidecarContext.ts | 22 --- public/app/core/services/SidecarService.ts | 91 ----------- .../extensions/usePluginExtensions.tsx | 5 +- .../app/features/plugins/extensions/utils.tsx | 18 ++- public/app/routes/RoutesWrapper.tsx | 11 +- yarn.lock | 1 + 11 files changed, 220 insertions(+), 131 deletions(-) create mode 100644 packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts create mode 100644 packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts delete mode 100644 public/app/core/context/SidecarContext.ts diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index cfc45d61897..2cf8dacaeab 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -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" }, diff --git a/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts b/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts new file mode 100644 index 00000000000..1597fb25b39 --- /dev/null +++ b/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts @@ -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( + 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), + }; +} diff --git a/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts new file mode 100644 index 00000000000..dd7fa8bc6ee --- /dev/null +++ b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts @@ -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; + private _initialContext: BehaviorSubject; + 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(initialId); + this._initialContext = new BehaviorSubject(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'; +} diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 8f9aaa6281f..ee4d014e3c9 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -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, diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 8056cbccb85..a4df2c1dd76 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -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 { options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }} > - +
{config.featureToggles.appSidecar ? ( @@ -108,7 +112,7 @@ export class AppWrapper extends Component {
-
+ diff --git a/public/app/core/context/SidecarContext.ts b/public/app/core/context/SidecarContext.ts deleted file mode 100644 index c6b341295c6..00000000000 --- a/public/app/core/context/SidecarContext.ts +++ /dev/null @@ -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); - -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), - }; -} diff --git a/public/app/core/services/SidecarService.ts b/public/app/core/services/SidecarService.ts index c3041d432f5..e69de29bb2d 100644 --- a/public/app/core/services/SidecarService.ts +++ b/public/app/core/services/SidecarService.ts @@ -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; - 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(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'; -} diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index 2ab882108de..2b89667aefb 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -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(() => { diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 5f92e51b7f6..4489faca6c0 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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 diff --git a/public/app/routes/RoutesWrapper.tsx b/public/app/routes/RoutesWrapper.tsx index 94ecc4f54b0..c65dcc9e1b4 100644 --- a/public/app/routes/RoutesWrapper.tsx +++ b/public/app/routes/RoutesWrapper.tsx @@ -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', diff --git a/yarn.lock b/yarn.lock index 7cd99a8f0b4..bdc8e5f1a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"