mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Sidecar: Move service to runtime (#94860)
This commit is contained in:
parent
b7e658c5c3
commit
c3c1f6ac27
@ -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"
|
||||
},
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
@ -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';
|
||||
}
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
@ -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';
|
||||
}
|
@ -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(() => {
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user