3
0
mirror of https://github.com/grafana/grafana.git synced 2025-02-25 18:55:37 -06:00

Plugins: remove deprecated code (components) ()

* refactor(plugins): use routes specific to the new plugins/admin

* refactor(plugins): remove unused pages (PluginList, PluginItem)

* refactor(plugins): remove PluginPage

* refactor(plugins): remove UpdatePluginModal

* refactor(plugins): move AppConfigWrapper under plugins/admin

* refactor(plugins): move PluginDashboards under plugins/admin

* refactor(plugins): rename the "specs" folder to "tests"

* refactor(plugins): move test files to /tests folder

* refactor(plugins): move AppRootPage into a /components folder

* refactor(plugins): move PluginsErrorsInfo into a /plugins folder

* refactor(plugins): move PluginSettingsCache into a /components folder

* refactor(plugins): move PluginStateInfo into a /plugins folder

* refactor(plugins): move AppRootPage.test.tsx next to the tested component

* refactor(plugins): remove old snapshot tests

* fix(plugins): fix tests

* refactor(plugins/admin): move & rename PluginSettingsCache

* fix(plugins): fix a few rebase issues

* Plugins: remove deprecated code (state handling) ()

* refactor(plugins): use the plugins/admin reducer only

* refactor(plugins): remove tests for the deprecated plugins reducer

* refactor(plugins): remove tests for the deprecated plugins selectors

* refactor(plugins/state): add a short comment note to selectors

* feat(plugins/state): add a selector for selecting errors

* feat(plugins/state): add a hook for getting plugin errors

* refactor(plugins): udpate the PluginsErrorsInfo component to use the new state selectors

* refactor(plugins/state): remove the old (deprecated) selectors

* refactor(plugins/state): use the new actions under /admin

* refactor(plugins/state): remove old (deprecated) reducers and actions

* refactor(plugins): update component definition

* fix(plugins): remove unnecessary {children} prop for PluginsErrorsInfo

* Plugins: show / hide install controls based on the `pluginAdminEnabled` flag ()

* docs(plugins): update documentation for the `plugin_admin_enabled` flag

* refactor(InstallControls): move the main component to a named module

* feat(plugins): use the `pluginAdminEnable` flag to hide / show install controls in the UI

* test(plugins): add tests for enabling/disabling install controls
This commit is contained in:
Levente Balogh 2021-11-19 13:42:26 +01:00 committed by GitHub
parent 98f87c4c49
commit 35c2c95fdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 285 additions and 1858 deletions

View File

@ -968,7 +968,7 @@ enable_alpha = false
app_tls_skip_verify_insecure = false
# Enter a comma-separated list of plugin identifiers to identify plugins to load even if they are unsigned. Plugins with modified signatures are never loaded.
allow_loading_unsigned_plugins =
# Enable or disable installing plugins directly from within Grafana.
# Enable or disable installing / uninstalling / updating plugins directly from within Grafana.
plugin_admin_enabled = true
plugin_admin_external_manage_enabled = false
plugin_catalog_url = https://grafana.com/grafana/plugins/

View File

@ -941,7 +941,7 @@
;app_tls_skip_verify_insecure = false
# Enter a comma-separated list of plugin identifiers to identify plugins to load even if they are unsigned. Plugins with modified signatures are never loaded.
;allow_loading_unsigned_plugins =
# Enable or disable installing plugins directly from within Grafana.
# Enable or disable installing / uninstalling / updating plugins directly from within Grafana.
;plugin_admin_enabled = false
;plugin_admin_external_manage_enabled = false
;plugin_catalog_url = https://grafana.com/grafana/plugins/

View File

@ -1582,7 +1582,7 @@ We do _not_ recommend using this option. For more information, refer to [Plugin
### plugin_admin_enabled
Available to Grafana administrators only, the plugin admin app is set to `true` by default. Set it to `false` to disable the app.
Available to Grafana administrators only, enables installing / uninstalling / updating plugins directly from the Grafana UI. Set to `true` by default. Setting it to `false` will hide the install / uninstall / update controls.
For more information, refer to [Plugin catalog]({{< relref "../plugins/catalog.md" >}}).

View File

@ -23,7 +23,7 @@ The Plugin catalog allows you to browse and manage plugins from within Grafana.
</video>
</div>
Before you can use the Plugin catalog, you must enable it in the Grafana [configuration]({{< relref "../administration/configuration.md#plugin_admin_enabled" >}}) file.
In order to be able to install / uninstall / update plugins using plugin catalog, you must enable it via the `plugin_admin_enabled` flag in the [configuration]({{< relref "../administration/configuration.md#plugin_admin_enabled" >}}) file.
Before following the steps below, make sure you are logged in as a Grafana administrator.
<a id="#plugin-catalog-entry"></a>

View File

@ -7,7 +7,7 @@ import apiKeysReducers from 'app/features/api-keys/state/reducers';
import foldersReducers from 'app/features/folders/state/reducers';
import dashboardReducers from 'app/features/dashboard/state/reducers';
import exploreReducers from 'app/features/explore/state/main';
import pluginReducers from 'app/features/plugins/state/reducers';
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import userReducers from 'app/features/profile/state/reducers';
@ -26,7 +26,6 @@ const rootReducers = {
...foldersReducers,
...dashboardReducers,
...exploreReducers,
...pluginReducers,
...dataSourcesReducers,
...usersReducers,
...userReducers,
@ -36,6 +35,7 @@ const rootReducers = {
...importDashboardReducers,
...panelEditorReducers,
...panelsReducers,
plugins: pluginsReducer,
};
const addedReducers = {};

View File

@ -9,7 +9,7 @@ import {
} from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction';
import { TransformationsEditorTransformation } from './types';
import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { useToggle } from 'react-use';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';

View File

@ -33,7 +33,7 @@ import { TransformationsEditorTransformation } from './types';
import { PanelNotSupported } from '../PanelEditor/PanelNotSupported';
import { AppNotificationSeverity } from '../../../../types';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed';

View File

@ -2,7 +2,7 @@
import { getBackendSrv } from '@grafana/runtime';
import { createSuccessNotification } from 'app/core/copy/appNotification';
// Actions
import { loadPluginDashboards } from '../../plugins/state/actions';
import { loadPluginDashboards } from '../../plugins/admin/state/actions';
import { cleanUpDashboard, loadDashboardPermissions } from './reducers';
import { notifyApp } from 'app/core/actions';
import { updateTimeZoneForSession, updateWeekStartForSession } from 'app/features/profile/state/reducers';

View File

@ -9,7 +9,7 @@ import DashboardTable from './DashboardsTable';
// Actions & Selectors
import { getNavModel } from 'app/core/selectors/navModel';
import { loadDataSource } from './state/actions';
import { loadPluginDashboards } from '../plugins/state/actions';
import { loadPluginDashboards } from '../plugins/admin/state/actions';
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
import { getDataSource } from './state/selectors';

View File

@ -10,7 +10,7 @@ import { addDataSource, loadDataSourcePlugins } from './state/actions';
import { getDataSourcePlugins } from './state/selectors';
import { setDataSourceTypeSearchQuery } from './state/reducers';
import { Card } from 'app/core/components/Card/Card';
import { PluginsErrorsInfo } from '../plugins/PluginsErrorsInfo';
import { PluginsErrorsInfo } from '../plugins/components/PluginsErrorsInfo';
function mapStateToProps(state: StoreState) {
return {

View File

@ -24,7 +24,7 @@ import { StoreState, AccessControlAction } from 'app/types/';
import { DataSourceSettings, urlUtil } from '@grafana/data';
import { Alert, Button } from '@grafana/ui';
import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from '../state/navModel';
import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers';
import { selectors } from '@grafana/e2e-selectors';
import { CloudInfoBox } from './CloudInfoBox';

View File

@ -5,7 +5,7 @@ import { updateNavIndex } from 'app/core/actions';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
import { getPluginSettings } from 'app/features/plugins/PluginSettingsCache';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { DataSourcePluginCategory, ThunkDispatch, ThunkResult } from 'app/types';
import config from '../../../core/config';

View File

@ -3,7 +3,7 @@ import { GrafanaTheme2, isUnsignedPluginSignature, PanelPluginMeta, PluginState
import { IconButton, PluginSignatureBadge, useStyles2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import { selectors } from '@grafana/e2e-selectors';
import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
interface Props {
isCurrent: boolean;

View File

@ -1,6 +1,6 @@
import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { loadPanelPlugin } from 'app/features/plugins/state/actions';
import { loadPanelPlugin } from 'app/features/plugins/admin/state/actions';
import { ThunkResult } from 'app/types';
import { panelModelAndPluginReady } from './reducers';
import { LibraryElementDTO } from 'app/features/library-panels/types';

View File

@ -1,25 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import PluginList from './PluginList';
import { getMockPlugins } from './__mocks__/pluginMocks';
import { LayoutModes } from '@grafana/data';
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
plugins: getMockPlugins(5),
layoutMode: LayoutModes.Grid,
},
propOverrides
);
return shallow(<PluginList {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -1,24 +0,0 @@
import React, { FC } from 'react';
import PluginListItem from './PluginListItem';
import { PluginMeta } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
interface Props {
plugins: PluginMeta[];
}
const PluginList: FC<Props> = (props) => {
const { plugins } = props;
return (
<section className="card-section card-list-layout-list">
<ol className="card-list" aria-label={selectors.pages.PluginsList.list}>
{plugins.map((plugin, index) => {
return <PluginListItem plugin={plugin} key={`${plugin.name}-${index}`} />;
})}
</ol>
</section>
);
};
export default PluginList;

View File

@ -1,33 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import PluginListItem from './PluginListItem';
import { getMockPlugin } from './__mocks__/pluginMocks';
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
plugin: getMockPlugin(),
},
propOverrides
);
return shallow(<PluginListItem {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render has plugin section', () => {
const mockPlugin = getMockPlugin();
mockPlugin.hasUpdate = true;
const wrapper = setup({
plugin: mockPlugin,
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -1,41 +0,0 @@
import React, { FC } from 'react';
import { PluginMeta } from '@grafana/data';
import { PluginSignatureBadge } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
interface Props {
plugin: PluginMeta;
}
const PluginListItem: FC<Props> = (props) => {
const { plugin } = props;
return (
<li className="card-item-wrapper" aria-label={selectors.pages.PluginsList.listItem}>
<a className="card-item" href={`plugins/${plugin.id}/`}>
<div className="card-item-header">
<div className="card-item-type">{plugin.type}</div>
<div className="card-item-badge">
<PluginSignatureBadge status={plugin.signature} />
</div>
{plugin.hasUpdate && (
<div className="card-item-notice">
<span bs-tooltip="plugin.latestVersion">Update available!</span>
</div>
)}
</div>
<div className="card-item-body">
<figure className="card-item-figure">
<img src={plugin.info.logos.small} />
</figure>
<div className="card-item-details">
<div className="card-item-name">{plugin.name}</div>
<div className="card-item-sub-name">{`By ${plugin.info.author.name}`}</div>
</div>
</div>
</a>
</li>
);
};
export default PluginListItem;

View File

@ -1,93 +0,0 @@
import React from 'react';
import { PluginListPage, Props } from './PluginListPage';
import { NavModel, PluginErrorCode, PluginMeta } from '@grafana/data';
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { setPluginsSearchQuery } from './state/reducers';
import { render, screen, waitFor } from '@testing-library/react';
import { selectors } from '@grafana/e2e-selectors';
import { Provider } from 'react-redux';
import { configureStore } from '../../store/configureStore';
import { afterEach } from '../../../test/lib/common';
let errorsReturnMock: any = [];
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
const mockedRuntime = {
...original,
getBackendSrv: () => ({
get: () => {
return errorsReturnMock as any;
},
}),
};
mockedRuntime.config.pluginAdminEnabled = false;
return mockedRuntime;
});
const setup = (propOverrides?: object) => {
const store = configureStore();
const props: Props = {
navModel: {
main: {
text: 'Configuration',
},
node: {
text: 'Plugins',
},
} as NavModel,
plugins: [] as PluginMeta[],
searchQuery: '',
setPluginsSearchQuery: mockToolkitActionCreator(setPluginsSearchQuery),
loadPlugins: jest.fn(),
hasFetched: false,
};
Object.assign(props, propOverrides);
return render(
<Provider store={store}>
<PluginListPage {...props} />
</Provider>
);
};
describe('Render', () => {
afterEach(() => {
errorsReturnMock = [];
});
it('should render component', async () => {
errorsReturnMock = [];
setup();
await waitFor(() => {
expect(screen.queryByLabelText(selectors.pages.PluginsList.page)).toBeInTheDocument();
expect(screen.queryByLabelText(selectors.pages.PluginsList.list)).not.toBeInTheDocument();
});
});
it('should render list', async () => {
errorsReturnMock = [];
setup({
hasFetched: true,
});
await waitFor(() => {
expect(screen.queryByLabelText(selectors.pages.PluginsList.list)).toBeInTheDocument();
});
});
describe('Plugin signature errors', () => {
it('should render notice if there are plugins with signing errors', async () => {
errorsReturnMock = [{ pluginId: 'invalid-sig', errorCode: PluginErrorCode.invalidSignature }];
setup({
hasFetched: true,
});
await waitFor(() =>
expect(screen.getByLabelText(selectors.pages.PluginsList.signatureErrorNotice)).toBeInTheDocument()
);
});
});
});

View File

@ -1,67 +0,0 @@
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import Page from 'app/core/components/Page/Page';
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
import PluginList from './PluginList';
import { loadPlugins } from './state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getPlugins, getPluginsSearchQuery } from './state/selectors';
import { StoreState } from 'app/types';
import { setPluginsSearchQuery } from './state/reducers';
import { useAsync } from 'react-use';
import { selectors } from '@grafana/e2e-selectors';
import { PluginsErrorsInfo } from './PluginsErrorsInfo';
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'plugins'),
plugins: getPlugins(state.plugins),
searchQuery: getPluginsSearchQuery(state.plugins),
hasFetched: state.plugins.hasFetched,
});
const mapDispatchToProps = {
loadPlugins,
setPluginsSearchQuery,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = ConnectedProps<typeof connector>;
export const PluginListPage: React.FC<Props> = ({
hasFetched,
navModel,
plugins,
setPluginsSearchQuery,
searchQuery,
loadPlugins,
}) => {
useAsync(async () => {
loadPlugins();
}, [loadPlugins]);
let actionTarget: string | undefined = '_blank';
const linkButton = {
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
title: 'Find more plugins on Grafana.com',
};
return (
<Page navModel={navModel} aria-label={selectors.pages.PluginsList.page}>
<Page.Contents isLoading={!hasFetched}>
<>
<PageActionBar
searchQuery={searchQuery}
setSearchQuery={(query) => setPluginsSearchQuery(query)}
linkButton={linkButton}
placeholder="Search by name, author, description or type"
target={actionTarget}
/>
<PluginsErrorsInfo />
{hasFetched && plugins && <PluginList plugins={plugins} />}
</>
</Page.Contents>
</Page>
);
};
export default connect(mapStateToProps, mapDispatchToProps)(PluginListPage);

View File

@ -1,585 +0,0 @@
// Libraries
import React, { PureComponent } from 'react';
import { capitalize, find } from 'lodash';
// Types
import {
AppPlugin,
GrafanaPlugin,
GrafanaTheme2,
NavModel,
NavModelItem,
PanelPluginMeta,
PluginDependencies,
PluginInclude,
PluginIncludeType,
PluginMeta,
PluginMetaInfo,
PluginSignatureStatus,
PluginSignatureType,
PluginType,
UrlQueryMap,
} from '@grafana/data';
import { AppNotificationSeverity } from 'app/types';
import { Alert, Badge, Icon, LinkButton, PluginSignatureBadge, Tooltip, useStyles2 } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { getPluginSettings } from './PluginSettingsCache';
import { importAppPlugin, importDataSourcePlugin } from './plugin_loader';
import { importPanelPluginFromMeta } from './importPanelPlugin';
import { getNotFoundNav } from 'app/angular/services/nav_model_srv';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { AppConfigCtrlWrapper } from './wrappers/AppConfigWrapper';
import { PluginDashboards } from './PluginDashboards';
import { appEvents } from 'app/core/core';
import { config } from 'app/core/config';
import { contextSrv } from '../../core/services/context_srv';
import { css } from '@emotion/css';
import { selectors } from '@grafana/e2e-selectors';
import { ShowModalReactEvent } from 'app/types/events';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { UpdatePluginModal } from './UpdatePluginModal';
interface Props extends GrafanaRouteComponentProps<{ pluginId: string }, UrlQueryMap> {}
interface State {
loading: boolean;
plugin?: GrafanaPlugin;
nav: NavModel;
defaultPage: string; // The first configured one or readme
}
const PAGE_ID_README = 'readme';
const PAGE_ID_DASHBOARDS = 'dashboards';
const PAGE_ID_CONFIG_CTRL = 'config';
class PluginPage extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
nav: getLoadingNav(),
defaultPage: PAGE_ID_README,
};
}
async componentDidMount() {
try {
const { location, queryParams } = this.props;
const { appSubUrl } = config;
const plugin = await loadPlugin(this.props.match.params.pluginId);
const { defaultPage, nav } = getPluginTabsNav(
plugin,
appSubUrl,
location.pathname,
queryParams,
contextSrv.hasRole('Admin')
);
this.setState({
loading: false,
plugin,
defaultPage,
nav,
});
} catch {
this.setState({
loading: false,
nav: getNotFoundNav(),
});
}
}
componentDidUpdate(prevProps: Props) {
const prevPage = prevProps.queryParams.page as string;
const page = this.props.queryParams.page as string;
if (prevPage !== page) {
const { nav, defaultPage } = this.state;
const node = {
...nav.node,
children: setActivePage(page, nav.node.children!, defaultPage),
};
this.setState({
nav: {
node: node,
main: node,
},
});
}
}
renderBody() {
const { queryParams } = this.props;
const { plugin, nav } = this.state;
if (!plugin) {
return <Alert severity={AppNotificationSeverity.Error} title="Plugin Not Found" />;
}
const active = nav.main.children!.find((tab) => tab.active);
if (active) {
// Find the current config tab
if (plugin.configPages) {
for (const tab of plugin.configPages) {
if (tab.id === active.id) {
return <tab.body plugin={plugin} query={queryParams} />;
}
}
}
// Apps have some special behavior
if (plugin.meta.type === PluginType.app) {
if (active.id === PAGE_ID_DASHBOARDS) {
return <PluginDashboards plugin={plugin.meta} />;
}
if (active.id === PAGE_ID_CONFIG_CTRL && plugin.angularConfigCtrl) {
return <AppConfigCtrlWrapper app={plugin as AppPlugin} />;
}
}
}
return <PluginHelp plugin={plugin.meta} type="help" />;
}
showUpdateInfo = () => {
const { id, name } = this.state.plugin!.meta;
appEvents.publish(
new ShowModalReactEvent({
props: {
id,
name,
},
component: UpdatePluginModal,
})
);
};
renderVersionInfo(meta: PluginMeta) {
if (!meta.info.version) {
return null;
}
return (
<section className="page-sidebar-section">
<h4>Version</h4>
<span>{meta.info.version}</span>
{meta.hasUpdate && (
<div>
<Tooltip content={meta.latestVersion!} theme="info" placement="top">
<LinkButton fill="text" onClick={this.showUpdateInfo}>
Update Available!
</LinkButton>
</Tooltip>
</div>
)}
</section>
);
}
renderSidebarIncludeBody(item: PluginInclude) {
if (item.type === PluginIncludeType.page) {
const pluginId = this.state.plugin!.meta.id;
const page = item.name.toLowerCase().replace(' ', '-');
const url = item.path ?? `plugins/${pluginId}/page/${page}`;
return (
<a href={url}>
<i className={getPluginIcon(item.type)} />
{item.name}
</a>
);
}
return (
<>
<i className={getPluginIcon(item.type)} />
{item.name}
</>
);
}
renderSidebarIncludes(includes?: PluginInclude[]) {
if (!includes || !includes.length) {
return null;
}
return (
<section className="page-sidebar-section">
<h4>Includes</h4>
<ul className="ui-list plugin-info-list">
{includes.map((include) => {
return (
<li className="plugin-info-list-item" key={include.name}>
{this.renderSidebarIncludeBody(include)}
</li>
);
})}
</ul>
</section>
);
}
renderSidebarDependencies(dependencies?: PluginDependencies) {
if (!dependencies) {
return null;
}
return (
<section className="page-sidebar-section">
<h4>Dependencies</h4>
<ul className="ui-list plugin-info-list">
<li className="plugin-info-list-item">
<img src="public/img/grafana_icon.svg" alt="Grafana logo" />
Grafana {dependencies.grafanaVersion}
</li>
{dependencies.plugins &&
dependencies.plugins.map((plug) => {
return (
<li className="plugin-info-list-item" key={plug.name}>
<i className={getPluginIcon(plug.type)} />
{plug.name} {plug.version}
</li>
);
})}
</ul>
</section>
);
}
renderSidebarLinks(info: PluginMetaInfo) {
if (!info.links || !info.links.length) {
return null;
}
return (
<section className="page-sidebar-section">
<h4>Links</h4>
<ul className="ui-list">
{info.links.map((link) => {
return (
<li key={link.url}>
<a href={link.url} className="external-link" target="_blank" rel="noreferrer noopener">
{link.name}
</a>
</li>
);
})}
</ul>
</section>
);
}
renderPluginNotice() {
const { plugin } = this.state;
if (!plugin) {
return null;
}
const isSignatureValid = plugin.meta.signature === PluginSignatureStatus.valid;
if (plugin.meta.signature === PluginSignatureStatus.internal) {
return null;
}
return (
<Alert
aria-label={selectors.pages.PluginPage.signatureInfo}
severity={isSignatureValid ? 'info' : 'warning'}
title="Plugin signature"
>
<div
className={css`
display: flex;
`}
>
<PluginSignatureBadge
status={plugin.meta.signature}
className={css`
margin-top: 0;
`}
/>
{isSignatureValid && (
<PluginSignatureDetailsBadge
signatureType={plugin.meta.signatureType}
signatureOrg={plugin.meta.signatureOrg}
/>
)}
</div>
<br />
<p>
Grafana Labs checks each plugin to verify that it has a valid digital signature. Plugin signature verification
is part of our security measures to ensure plugins are safe and trustworthy.{' '}
{!isSignatureValid &&
'Grafana Labs cant guarantee the integrity of this unsigned plugin. Ask the plugin author to request it to be signed.'}
</p>
<a
href="https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/"
className="external-link"
target="_blank"
rel="noreferrer"
>
Read more about plugins signing.
</a>
</Alert>
);
}
render() {
const { loading, nav, plugin } = this.state;
const isAdmin = contextSrv.hasRole('Admin');
return (
<Page navModel={nav} aria-label={selectors.pages.PluginPage.page}>
<Page.Contents isLoading={loading}>
{plugin && (
<div className="sidebar-container">
<div className="sidebar-content">
{plugin.loadError && (
<Alert severity={AppNotificationSeverity.Error} title="Error Loading Plugin">
<>
Check the server startup logs for more information. <br />
If this plugin was loaded from git, make sure it was compiled.
</>
</Alert>
)}
{this.renderPluginNotice()}
{this.renderBody()}
</div>
<aside className="page-sidebar">
<section className="page-sidebar-section">
{this.renderVersionInfo(plugin.meta)}
{isAdmin && this.renderSidebarIncludes(plugin.meta.includes)}
{this.renderSidebarDependencies(plugin.meta.dependencies)}
{this.renderSidebarLinks(plugin.meta.info)}
</section>
</aside>
</div>
)}
</Page.Contents>
</Page>
);
}
}
function getPluginTabsNav(
plugin: GrafanaPlugin,
appSubUrl: string,
path: string,
query: UrlQueryMap,
isAdmin: boolean
): { defaultPage: string; nav: NavModel } {
const { meta } = plugin;
let defaultPage: string | undefined;
const pages: NavModelItem[] = [];
pages.push({
text: 'Readme',
icon: 'file-alt',
url: `${appSubUrl}${path}?page=${PAGE_ID_README}`,
id: PAGE_ID_README,
});
// We allow non admins to see plugins but only their readme. Config is hidden
// even though the API needs to be public for plugins to work properly.
if (isAdmin) {
// Only show Config/Pages for app
if (meta.type === PluginType.app) {
// Legacy App Config
if (plugin.angularConfigCtrl) {
pages.push({
text: 'Config',
icon: 'cog',
url: `${appSubUrl}${path}?page=${PAGE_ID_CONFIG_CTRL}`,
id: PAGE_ID_CONFIG_CTRL,
});
defaultPage = PAGE_ID_CONFIG_CTRL;
}
if (plugin.configPages) {
for (const page of plugin.configPages) {
pages.push({
text: page.title,
icon: page.icon,
url: `${appSubUrl}${path}?page=${page.id}`,
id: page.id,
});
if (!defaultPage) {
defaultPage = page.id;
}
}
}
// Check for the dashboard pages
if (find(meta.includes, { type: PluginIncludeType.dashboard })) {
pages.push({
text: 'Dashboards',
icon: 'apps',
url: `${appSubUrl}${path}?page=${PAGE_ID_DASHBOARDS}`,
id: PAGE_ID_DASHBOARDS,
});
}
}
}
if (!defaultPage) {
defaultPage = pages[0].id; // the first tab
}
const node = {
text: meta.name,
img: meta.info.logos.large,
subTitle: meta.info.author.name,
breadcrumbs: [{ title: 'Plugins', url: 'plugins' }],
url: `${appSubUrl}${path}`,
children: setActivePage(query.page as string, pages, defaultPage!),
};
return {
defaultPage: defaultPage!,
nav: {
node: node,
main: node,
},
};
}
function setActivePage(pageId: string, pages: NavModelItem[], defaultPageId: string): NavModelItem[] {
let found = false;
const selected = pageId || defaultPageId;
const changed = pages.map((p) => {
const active = !found && selected === p.id;
if (active) {
found = true;
}
return { ...p, active };
});
if (!found) {
changed[0].active = true;
}
return changed;
}
function getPluginIcon(type: string) {
switch (type) {
case 'datasource':
return 'gicon gicon-datasources';
case 'panel':
return 'icon-gf icon-gf-panel';
case 'app':
return 'icon-gf icon-gf-apps';
case 'page':
return 'icon-gf icon-gf-endpoint-tiny';
case 'dashboard':
return 'gicon gicon-dashboard';
default:
return 'icon-gf icon-gf-apps';
}
}
export function getLoadingNav(): NavModel {
const node = {
text: 'Loading...',
icon: 'icon-gf icon-gf-panel',
};
return {
node: node,
main: node,
};
}
export async function loadPlugin(pluginId: string): Promise<GrafanaPlugin> {
const info = await getPluginSettings(pluginId);
let result: GrafanaPlugin | undefined;
if (info.type === PluginType.app) {
result = await importAppPlugin(info);
}
if (info.type === PluginType.datasource) {
result = await importDataSourcePlugin(info);
}
if (info.type === PluginType.panel) {
const panelPlugin = await importPanelPluginFromMeta(info as PanelPluginMeta);
result = (panelPlugin as unknown) as GrafanaPlugin;
}
if (info.type === PluginType.renderer) {
result = { meta: info } as GrafanaPlugin;
}
if (!result) {
throw new Error('Unknown Plugin type: ' + info.type);
}
return result;
}
type PluginSignatureDetailsBadgeProps = {
signatureType?: PluginSignatureType;
signatureOrg?: string;
};
const PluginSignatureDetailsBadge: React.FC<PluginSignatureDetailsBadgeProps> = ({ signatureType, signatureOrg }) => {
const styles = useStyles2(getDetailsBadgeStyles);
if (!signatureType && !signatureOrg) {
return null;
}
const signatureTypeIcon =
signatureType === PluginSignatureType.grafana
? 'grafana'
: signatureType === PluginSignatureType.commercial || signatureType === PluginSignatureType.community
? 'shield'
: 'shield-exclamation';
const signatureTypeText = signatureType === PluginSignatureType.grafana ? 'Grafana Labs' : capitalize(signatureType);
return (
<>
{signatureType && (
<Badge
color="green"
className={styles.badge}
text={
<>
<strong className={styles.strong}>Level:&nbsp;</strong>
<Icon size="xs" name={signatureTypeIcon} />
&nbsp;
{signatureTypeText}
</>
}
/>
)}
{signatureOrg && (
<Badge
color="green"
className={styles.badge}
text={
<>
<strong className={styles.strong}>Signed by:</strong> {signatureOrg}
</>
}
/>
)}
</>
);
};
const getDetailsBadgeStyles = (theme: GrafanaTheme2) => ({
badge: css`
background-color: ${theme.colors.background.canvas};
border-color: ${theme.colors.border.strong};
color: ${theme.colors.text.secondary};
margin-left: ${theme.spacing()};
`,
strong: css`
color: ${theme.colors.text.primary};
`,
icon: css`
margin-right: ${theme.spacing(0.5)};
`,
});
export default PluginPage;

View File

@ -1,58 +0,0 @@
import React from 'react';
import { Modal, useStyles2, VerticalGroup } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
export interface UpdatePluginModalProps {
onDismiss: () => void;
id: string;
name: string;
}
export function UpdatePluginModal({ onDismiss, id, name }: UpdatePluginModalProps): JSX.Element {
const styles = useStyles2(getStyles);
return (
<Modal title="Update Plugin" onDismiss={onDismiss} onClickBackdrop={onDismiss} isOpen>
<VerticalGroup spacing="md">
<VerticalGroup spacing="sm">
<p>Type the following on the command line to update {name}.</p>
<pre>
<code>grafana-cli plugins update {id}</code>
</pre>
<span className={styles.small}>
Check out {name} on <a href={`https://grafana.com/plugins/${id}`}>Grafana.com</a> for README and changelog.
If you do not have access to the command line, ask your Grafana administator.
</span>
</VerticalGroup>
<p className={styles.weak}>
<img className={styles.logo} src="public/img/grafana_icon.svg" alt="grafana logo" />
<strong>Pro tip</strong>: To update all plugins at once, type{' '}
<code className={styles.codeSmall}>grafana-cli plugins update-all</code> on the command line.
</p>
</VerticalGroup>
</Modal>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
small: css`
font-size: ${theme.typography.bodySmall.fontSize};
font-weight: ${theme.typography.bodySmall.fontWeight};
`,
weak: css`
color: ${theme.colors.text.disabled};
font-size: ${theme.typography.bodySmall.fontSize};
`,
logo: css`
vertical-align: sub;
margin-right: ${theme.spacing(0.3)};
width: ${theme.spacing(2)};
`,
codeSmall: css`
white-space: nowrap;
margin: 0 ${theme.spacing(0.25)};
padding: ${theme.spacing(0.25)};
`,
};
}

View File

@ -1,241 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<section
className="card-section card-list-layout-list"
>
<ol
aria-label="Plugins list"
className="card-list"
>
<PluginListItem
key="pretty cool plugin-0-0"
plugin={
Object {
"defaultNavUrl": "some/url",
"enabled": false,
"hasUpdate": false,
"id": "0",
"info": Object {
"author": Object {
"name": "Grafana Labs",
"url": "url/to/GrafanaLabs",
},
"description": "pretty decent plugin",
"links": Array [
"one link",
],
"logos": Object {
"large": "large/logo",
"small": "small/logo",
},
"screenshots": Array [
Object {
"path": "screenshot/0",
},
],
"updated": "2018-09-26",
"version": "1",
},
"latestVersion": "1.0",
"module": Object {},
"name": "pretty cool plugin-0",
"pinned": false,
"state": "",
"type": "",
}
}
/>
<PluginListItem
key="pretty cool plugin-1-1"
plugin={
Object {
"defaultNavUrl": "some/url",
"enabled": false,
"hasUpdate": false,
"id": "1",
"info": Object {
"author": Object {
"name": "Grafana Labs",
"url": "url/to/GrafanaLabs",
},
"description": "pretty decent plugin",
"links": Array [
"one link",
],
"logos": Object {
"large": "large/logo",
"small": "small/logo",
},
"screenshots": Array [
Object {
"path": "screenshot/1",
},
],
"updated": "2018-09-26",
"version": "1",
},
"latestVersion": "1.1",
"module": Object {},
"name": "pretty cool plugin-1",
"pinned": false,
"state": "",
"type": "",
}
}
/>
<PluginListItem
key="pretty cool plugin-2-2"
plugin={
Object {
"defaultNavUrl": "some/url",
"enabled": false,
"hasUpdate": false,
"id": "2",
"info": Object {
"author": Object {
"name": "Grafana Labs",
"url": "url/to/GrafanaLabs",
},
"description": "pretty decent plugin",
"links": Array [
"one link",
],
"logos": Object {
"large": "large/logo",
"small": "small/logo",
},
"screenshots": Array [
Object {
"path": "screenshot/2",
},
],
"updated": "2018-09-26",
"version": "1",
},
"latestVersion": "1.2",
"module": Object {},
"name": "pretty cool plugin-2",
"pinned": false,
"state": "",
"type": "",
}
}
/>
<PluginListItem
key="pretty cool plugin-3-3"
plugin={
Object {
"defaultNavUrl": "some/url",
"enabled": false,
"hasUpdate": false,
"id": "3",
"info": Object {
"author": Object {
"name": "Grafana Labs",
"url": "url/to/GrafanaLabs",
},
"description": "pretty decent plugin",
"links": Array [
"one link",
],
"logos": Object {
"large": "large/logo",
"small": "small/logo",
},
"screenshots": Array [
Object {
"path": "screenshot/3",
},
],
"updated": "2018-09-26",
"version": "1",
},
"latestVersion": "1.3",
"module": Object {},
"name": "pretty cool plugin-3",
"pinned": false,
"state": "",
"type": "",
}
}
/>
<PluginListItem
key="pretty cool plugin-4-4"
plugin={
Object {
"defaultNavUrl": "some/url",
"enabled": false,
"hasUpdate": false,
"id": "4",
"info": Object {
"author": Object {
"name": "Grafana Labs",
"url": "url/to/GrafanaLabs",
},
"description": "pretty decent plugin",
"links": Array [
"one link",
],
"logos": Object {
"large": "large/logo",
"small": "small/logo",
},
"screenshots": Array [
Object {
"path": "screenshot/4",
},
],
"updated": "2018-09-26",
"version": "1",
},
"latestVersion": "1.4",
"module": Object {},
"name": "pretty cool plugin-4",
"pinned": false,
"state": "",
"type": "",
}
}
/>
<PluginListItem
key="pretty cool plugin-5-5"
plugin={
Object {
"defaultNavUrl": "some/url",
"enabled": false,
"hasUpdate": false,
"id": "5",
"info": Object {
"author": Object {
"name": "Grafana Labs",
"url": "url/to/GrafanaLabs",
},
"description": "pretty decent plugin",
"links": Array [
"one link",
],
"logos": Object {
"large": "large/logo",
"small": "small/logo",
},
"screenshots": Array [
Object {
"path": "screenshot/5",
},
],
"updated": "2018-09-26",
"version": "1",
},
"latestVersion": "1.5",
"module": Object {},
"name": "pretty cool plugin-5",
"pinned": false,
"state": "",
"type": "",
}
}
/>
</ol>
</section>
`;

View File

@ -1,114 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<li
aria-label="Plugins list item"
className="card-item-wrapper"
>
<a
className="card-item"
href="plugins/1/"
>
<div
className="card-item-header"
>
<div
className="card-item-type"
>
panel
</div>
<div
className="card-item-badge"
>
<PluginSignatureBadge />
</div>
</div>
<div
className="card-item-body"
>
<figure
className="card-item-figure"
>
<img
src="small/logo"
/>
</figure>
<div
className="card-item-details"
>
<div
className="card-item-name"
>
pretty cool plugin 1
</div>
<div
className="card-item-sub-name"
>
By Grafana Labs
</div>
</div>
</div>
</a>
</li>
`;
exports[`Render should render has plugin section 1`] = `
<li
aria-label="Plugins list item"
className="card-item-wrapper"
>
<a
className="card-item"
href="plugins/1/"
>
<div
className="card-item-header"
>
<div
className="card-item-type"
>
panel
</div>
<div
className="card-item-badge"
>
<PluginSignatureBadge />
</div>
<div
className="card-item-notice"
>
<span
bs-tooltip="plugin.latestVersion"
>
Update available!
</span>
</div>
</div>
<div
className="card-item-body"
>
<figure
className="card-item-figure"
>
<img
src="small/logo"
/>
</figure>
<div
className="card-item-details"
>
<div
className="card-item-name"
>
pretty cool plugin 1
</div>
<div
className="card-item-sub-name"
>
By Grafana Labs
</div>
</div>
</div>
</a>
</li>
`;

View File

@ -0,0 +1,119 @@
import React from 'react';
import { css } from '@emotion/css';
import { config } from '@grafana/runtime';
import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, PluginType } from '@grafana/data';
import { ExternallyManagedButton } from './ExternallyManagedButton';
import { InstallControlsButton } from './InstallControlsButton';
import { CatalogPlugin, PluginStatus, Version } from '../../types';
import { getExternalManageLink, isInstallControlsEnabled } from '../../helpers';
import { useIsRemotePluginsAvailable } from '../../state/hooks';
import { isGrafanaAdmin } from '../../permissions';
interface Props {
plugin: CatalogPlugin;
latestCompatibleVersion?: Version;
}
export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => {
const styles = useStyles2(getStyles);
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const hasPermission = isGrafanaAdmin();
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const isCompatible = Boolean(latestCompatibleVersion);
const isInstallControlsDisabled = plugin.isCore || plugin.isDisabled || !isInstallControlsEnabled();
const pluginStatus = plugin.isInstalled
? plugin.hasUpdate
? PluginStatus.UPDATE
: PluginStatus.UNINSTALL
: PluginStatus.INSTALL;
if (isInstallControlsDisabled) {
return null;
}
if (plugin.type === PluginType.renderer) {
return <div className={styles.message}>Renderer plugins cannot be managed by the Plugin Catalog.</div>;
}
if (plugin.isEnterprise && !config.licenseInfo?.hasValidLicense) {
return (
<HorizontalGroup height="auto" align="center">
<span className={styles.message}>No valid Grafana Enterprise license detected.</span>
<LinkButton
href={`${getExternalManageLink(plugin.id)}?utm_source=grafana_catalog_learn_more`}
target="_blank"
rel="noopener noreferrer"
size="sm"
fill="text"
icon="external-link-alt"
>
Learn more
</LinkButton>
</HorizontalGroup>
);
}
if (plugin.isDev) {
return (
<div className={styles.message}>This is a development build of the plugin and can&#39;t be uninstalled.</div>
);
}
if (!hasPermission && !isExternallyManaged) {
const message = `You do not have permission to ${pluginStatus} this plugin.`;
return <div className={styles.message}>{message}</div>;
}
if (!plugin.isPublished) {
return (
<div className={styles.message}>
<Icon name="exclamation-triangle" /> This plugin is not published to{' '}
<a href="https://www.grafana.com/plugins" target="__blank" rel="noreferrer">
grafana.com/plugins
</a>{' '}
and can&#39;t be managed via the catalog.
</div>
);
}
if (!isCompatible) {
return (
<div className={styles.message}>
<Icon name="exclamation-triangle" />
&nbsp;This plugin doesn&#39;t support your version of Grafana.
</div>
);
}
if (isExternallyManaged) {
return <ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />;
}
if (!isRemotePluginsAvailable) {
return (
<div className={styles.message}>
The install controls have been disabled because the Grafana server cannot access grafana.com.
</div>
);
}
return (
<InstallControlsButton
plugin={plugin}
pluginStatus={pluginStatus}
latestCompatibleVersion={latestCompatibleVersion}
/>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
message: css`
color: ${theme.colors.text.secondary};
`,
};
};

View File

@ -1,118 +1 @@
import React from 'react';
import { css } from '@emotion/css';
import { config } from '@grafana/runtime';
import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, PluginType } from '@grafana/data';
import { ExternallyManagedButton } from './ExternallyManagedButton';
import { InstallControlsButton } from './InstallControlsButton';
import { CatalogPlugin, PluginStatus, Version } from '../../types';
import { getExternalManageLink } from '../../helpers';
import { useIsRemotePluginsAvailable } from '../../state/hooks';
import { isGrafanaAdmin } from '../../permissions';
interface Props {
plugin: CatalogPlugin;
latestCompatibleVersion?: Version;
}
export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => {
const styles = useStyles2(getStyles);
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const hasPermission = isGrafanaAdmin();
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const isCompatible = Boolean(latestCompatibleVersion);
const pluginStatus = plugin.isInstalled
? plugin.hasUpdate
? PluginStatus.UPDATE
: PluginStatus.UNINSTALL
: PluginStatus.INSTALL;
if (plugin.isCore || plugin.isDisabled) {
return null;
}
if (plugin.type === PluginType.renderer) {
return <div className={styles.message}>Renderer plugins cannot be managed by the Plugin Catalog.</div>;
}
if (plugin.isEnterprise && !config.licenseInfo?.hasValidLicense) {
return (
<HorizontalGroup height="auto" align="center">
<span className={styles.message}>No valid Grafana Enterprise license detected.</span>
<LinkButton
href={`${getExternalManageLink(plugin.id)}?utm_source=grafana_catalog_learn_more`}
target="_blank"
rel="noopener noreferrer"
size="sm"
fill="text"
icon="external-link-alt"
>
Learn more
</LinkButton>
</HorizontalGroup>
);
}
if (plugin.isDev) {
return (
<div className={styles.message}>This is a development build of the plugin and can&#39;t be uninstalled.</div>
);
}
if (!hasPermission && !isExternallyManaged) {
const message = `You do not have permission to ${pluginStatus} this plugin.`;
return <div className={styles.message}>{message}</div>;
}
if (!plugin.isPublished) {
return (
<div className={styles.message}>
<Icon name="exclamation-triangle" /> This plugin is not published to{' '}
<a href="https://www.grafana.com/plugins" target="__blank" rel="noreferrer">
grafana.com/plugins
</a>{' '}
and can&#39;t be managed via the catalog.
</div>
);
}
if (!isCompatible) {
return (
<div className={styles.message}>
<Icon name="exclamation-triangle" />
&nbsp;This plugin doesn&#39;t support your version of Grafana.
</div>
);
}
if (isExternallyManaged) {
return <ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />;
}
if (!isRemotePluginsAvailable) {
return (
<div className={styles.message}>
The install controls have been disabled because the Grafana server cannot access grafana.com.
</div>
);
}
return (
<InstallControlsButton
plugin={plugin}
pluginStatus={pluginStatus}
latestCompatibleVersion={latestCompatibleVersion}
/>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
message: css`
color: ${theme.colors.text.secondary};
`,
};
};
export * from './InstallControls';

View File

@ -7,8 +7,8 @@ import { useStyles2 } from '@grafana/ui';
import { CatalogPlugin, PluginTabIds } from '../types';
import { VersionList } from '../components/VersionList';
import { usePluginConfig } from '../hooks/usePluginConfig';
import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper';
import { PluginDashboards } from '../../PluginDashboards';
import { AppConfigCtrlWrapper } from './AppConfigWrapper';
import { PluginDashboards } from './PluginDashboards';
type Props = {
plugin: CatalogPlugin;

View File

@ -261,6 +261,8 @@ export function getLatestCompatibleVersion(versions: Version[] | undefined): Ver
return latest;
}
export const isInstallControlsEnabled = () => config.pluginAdminEnabled;
export const isLocalPluginVisible = (p: LocalPlugin) => isPluginVisible(p.id);
export const isRemotePluginVisible = (p: RemotePlugin) => isPluginVisible(p.slug);

View File

@ -1,6 +1,6 @@
import { useAsync } from 'react-use';
import { CatalogPlugin } from '../types';
import { loadPlugin } from '../../PluginPage';
import { loadPlugin } from '../../utils';
export const usePluginConfig = (plugin?: CatalogPlugin) => {
return useAsync(async () => {

View File

@ -459,6 +459,25 @@ describe('Plugin details page', () => {
expect(rendered.getByText(message)).toBeInTheDocument();
});
it('should not display the install / uninstall / update buttons if `pluginAdminEnabled` flag is set to FALSE in the Grafana config', async () => {
let rendered: RenderResult;
// Disable the install controls for the plugins catalog
config.pluginAdminEnabled = false;
// Should not show an "Install" button
rendered = renderPluginDetails({ id, isInstalled: false });
await waitFor(() => expect(rendered.queryByRole('button', { name: /^install/i })).not.toBeInTheDocument());
// Should not show an "Uninstall" button
rendered = renderPluginDetails({ id, isInstalled: true });
await waitFor(() => expect(rendered.queryByRole('button', { name: /^uninstall/i })).not.toBeInTheDocument());
// Should not show an "Update" button
rendered = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
await waitFor(() => expect(rendered.queryByRole('button', { name: /^update/i })).not.toBeInTheDocument());
});
it('should display a "Create" button as a post installation step for installed data source plugins', async () => {
const name = 'Akumuli';
const { queryByText } = renderPluginDetails({

View File

@ -0,0 +1,48 @@
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { RouteDescriptor } from 'app/core/navigation/types';
import { isGrafanaAdmin } from './permissions';
import { PluginAdminRoutes } from './types';
const DEFAULT_ROUTES = [
{
path: '/plugins',
routeName: PluginAdminRoutes.Home,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')),
},
{
path: '/plugins/browse',
routeName: PluginAdminRoutes.Browse,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')),
},
{
path: '/plugins/:pluginId/',
routeName: PluginAdminRoutes.Details,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './pages/PluginDetails')),
},
];
const ADMIN_ROUTES = [
{
path: '/admin/plugins',
routeName: PluginAdminRoutes.HomeAdmin,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')),
},
{
path: '/admin/plugins/browse',
routeName: PluginAdminRoutes.BrowseAdmin,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')),
},
{
path: '/admin/plugins/:pluginId/',
routeName: PluginAdminRoutes.DetailsAdmin,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './pages/PluginDetails')),
},
];
export function getRoutes(): RouteDescriptor[] {
if (isGrafanaAdmin()) {
return [...DEFAULT_ROUTES, ...ADMIN_ROUTES];
}
return DEFAULT_ROUTES;
}

View File

@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { PluginError } from '@grafana/data';
import { setDisplayMode } from './reducer';
import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall } from './actions';
import { CatalogPlugin, PluginCatalogStoreState, PluginListDisplayMode } from '../types';
@ -11,6 +12,7 @@ import {
selectRequestError,
selectIsRequestNotFetched,
selectDisplayMode,
selectPluginErrors,
} from './selectors';
import { sortPlugins, Sorters } from '../helpers';
@ -53,6 +55,12 @@ export const useGetSingle = (id: string): CatalogPlugin | undefined => {
return useSelector((state: PluginCatalogStoreState) => selectById(state, id));
};
export const useGetErrors = (): PluginError[] => {
useFetchAll();
return useSelector(selectPluginErrors);
};
export const useInstall = () => {
const dispatch = useDispatch();
return (id: string, version?: string, isUpdating?: boolean) => dispatch(install({ id, version, isUpdating }));

View File

@ -1,4 +1,4 @@
import { createSlice, createEntityAdapter, AnyAction, PayloadAction } from '@reduxjs/toolkit';
import { createSlice, createEntityAdapter, Reducer, AnyAction, PayloadAction } from '@reduxjs/toolkit';
import { fetchAll, fetchDetails, install, uninstall, loadPluginDashboards, panelPluginLoaded } from './actions';
import { CatalogPlugin, PluginListDisplayMode, ReducerState, RequestStatus } from '../types';
import { STATE_PREFIX } from '../constants';
@ -97,4 +97,4 @@ const slice = createSlice({
});
export const { setDisplayMode } = slice.actions;
export const { reducer } = slice;
export const reducer: Reducer<ReducerState, AnyAction> = slice.reducer;

View File

@ -1,4 +1,5 @@
import { createSelector } from 'reselect';
import { createSelector } from '@reduxjs/toolkit';
import { PluginError, PluginErrorCode } from '@grafana/data';
import { RequestStatus, PluginCatalogStoreState } from '../types';
import { pluginsAdapter } from './reducer';
@ -49,6 +50,20 @@ export const find = (searchBy: string, filterBy: string, filterByType: string) =
}
);
export const selectPluginErrors = createSelector(selectAll, (plugins) =>
plugins
? plugins
.filter((p) => Boolean(p.error))
.map(
(p): PluginError => ({
pluginId: p.id,
errorCode: p!.error as PluginErrorCode,
})
)
: []
);
// The following selectors are used to get information about the outstanding or completed plugins-related network requests.
export const selectRequest = (actionType: string) =>
createSelector(selectRoot, ({ requests = {} }) => requests[actionType]);

View File

@ -1,19 +1,19 @@
import { act, render, screen } from '@testing-library/react';
import React, { Component } from 'react';
import AppRootPage from './AppRootPage';
import { getPluginSettings } from './PluginSettingsCache';
import { importAppPlugin } from './plugin_loader';
import { getMockPlugin } from './__mocks__/pluginMocks';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
import { getMockPlugin } from '../__mocks__/pluginMocks';
import { AppPlugin, PluginType, AppRootProps, NavModelItem } from '@grafana/data';
import { Route, Router } from 'react-router-dom';
import { locationService, setEchoSrv } from '@grafana/runtime';
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { Echo } from 'app/core/services/echo/Echo';
jest.mock('./PluginSettingsCache', () => ({
jest.mock('../pluginSettings', () => ({
getPluginSettings: jest.fn(),
}));
jest.mock('./plugin_loader', () => ({
jest.mock('../plugin_loader', () => ({
importAppPlugin: jest.fn(),
}));

View File

@ -4,8 +4,8 @@ import { AppEvents, AppPlugin, AppPluginMeta, KeyValue, NavModel, PluginType } f
import { createHtmlPortalNode, InPortal, OutPortal, HtmlPortalNode } from 'react-reverse-portal';
import Page from 'app/core/components/Page/Page';
import { getPluginSettings } from './PluginSettingsCache';
import { importAppPlugin } from './plugin_loader';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/angular/services/nav_model_srv';
import { appEvents } from 'app/core/core';
import PageLoader from 'app/core/components/PageLoader/PageLoader';

View File

@ -1,42 +1,19 @@
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { HorizontalGroup, InfoBox, List, PluginSignatureBadge, useTheme } from '@grafana/ui';
import { StoreState } from '../../types';
import { getAllPluginsErrors } from './state/selectors';
import { loadPlugins, loadPluginsErrors } from './state/actions';
import useAsync from 'react-use/lib/useAsync';
import { connect, ConnectedProps } from 'react-redux';
import { useGetErrors, useFetchStatus } from '../admin/state/hooks';
import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data';
import { css } from '@emotion/css';
const mapStateToProps = (state: StoreState) => ({
errors: getAllPluginsErrors(state.plugins),
});
const mapDispatchToProps = {
loadPluginsErrors,
};
interface OwnProps {
children?: React.ReactNode;
}
const connector = connect(mapStateToProps, mapDispatchToProps);
type PluginsErrorsInfoProps = ConnectedProps<typeof connector> & OwnProps;
export const PluginsErrorsInfoUnconnected: React.FC<PluginsErrorsInfoProps> = ({
loadPluginsErrors,
errors,
children,
}) => {
export function PluginsErrorsInfo(): React.ReactElement | null {
const errors = useGetErrors();
const { isLoading } = useFetchStatus();
const theme = useTheme();
const { loading } = useAsync(async () => {
await loadPluginsErrors();
}, [loadPlugins]);
if (loading || errors.length === 0) {
if (isLoading || errors.length === 0) {
return null;
}
return (
<InfoBox
aria-label={selectors.pages.PluginsList.signatureErrorNotice}
@ -55,16 +32,16 @@ export const PluginsErrorsInfoUnconnected: React.FC<PluginsErrorsInfoProps> = ({
className={css`
list-style-type: circle;
`}
renderItem={(e) => (
renderItem={(error) => (
<div
className={css`
margin-top: ${theme.spacing.sm};
`}
>
<HorizontalGroup spacing="sm" justify="flex-start" align="center">
<strong>{e.pluginId}</strong>
<strong>{error.pluginId}</strong>
<PluginSignatureBadge
status={mapPluginErrorCodeToSignatureStatus(e.errorCode)}
status={mapPluginErrorCodeToSignatureStatus(error.errorCode)}
className={css`
margin-top: 0;
`}
@ -73,13 +50,10 @@ export const PluginsErrorsInfoUnconnected: React.FC<PluginsErrorsInfoProps> = ({
</div>
)}
/>
{children}
</div>
</InfoBox>
);
};
export const PluginsErrorsInfo = connect(mapStateToProps, mapDispatchToProps)(PluginsErrorsInfoUnconnected);
}
function mapPluginErrorCodeToSignatureStatus(code: PluginErrorCode) {
switch (code) {

View File

@ -1,67 +0,0 @@
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
import { isGrafanaAdmin } from './admin/permissions';
import { PluginAdminRoutes } from './admin/types';
const pluginAdminRoutes = [
{
path: '/plugins',
routeName: PluginAdminRoutes.Home,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')),
},
{
path: '/plugins/browse',
routeName: PluginAdminRoutes.Browse,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')),
},
{
path: '/plugins/:pluginId/',
routeName: PluginAdminRoutes.Details,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')),
},
];
export function getPluginsAdminRoutes(cfg = config): RouteDescriptor[] {
if (!cfg.pluginAdminEnabled) {
return [
{
path: '/plugins',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './PluginListPage')),
},
{
path: '/plugins/browse',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "PluginAdminNotEnabled" */ './admin/pages/NotEnabed')
),
},
{
path: '/plugins/:pluginId/',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './PluginPage')),
},
];
}
if (isGrafanaAdmin()) {
return [
...pluginAdminRoutes,
{
path: '/admin/plugins',
routeName: PluginAdminRoutes.HomeAdmin,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')),
},
{
path: '/admin/plugins/browse',
routeName: PluginAdminRoutes.BrowseAdmin,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')),
},
{
path: '/admin/plugins/:pluginId/',
routeName: PluginAdminRoutes.DetailsAdmin,
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')),
},
];
}
return pluginAdminRoutes;
}

View File

@ -1,59 +0,0 @@
import { getBackendSrv } from '@grafana/runtime';
import { PanelPlugin } from '@grafana/data';
import { ThunkResult } from 'app/types';
import { config } from 'app/core/config';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import {
loadPanelPlugin as loadPanelPluginNew,
loadPluginDashboards as loadPluginDashboardsNew,
} from '../admin/state/actions';
import {
pluginDashboardsLoad,
pluginDashboardsLoaded,
pluginsLoaded,
panelPluginLoaded,
pluginsErrorsLoaded,
} from './reducers';
export function loadPlugins(): ThunkResult<void> {
return async (dispatch) => {
const plugins = await getBackendSrv().get('api/plugins', { embedded: 0 });
dispatch(pluginsLoaded(plugins));
};
}
export function loadPluginsErrors(): ThunkResult<void> {
return async (dispatch) => {
const errors = await getBackendSrv().get('api/plugins/errors');
dispatch(pluginsErrorsLoaded(errors));
};
}
function loadPluginDashboardsOriginal(): ThunkResult<void> {
return async (dispatch, getStore) => {
dispatch(pluginDashboardsLoad());
const dataSourceType = getStore().dataSources.dataSource.type;
const response = await getBackendSrv().get(`api/plugins/${dataSourceType}/dashboards`);
dispatch(pluginDashboardsLoaded(response));
};
}
function loadPanelPluginOriginal(pluginId: string): ThunkResult<Promise<PanelPlugin>> {
return async (dispatch, getStore) => {
let plugin = getStore().plugins.panels[pluginId];
if (!plugin) {
plugin = await importPanelPlugin(pluginId);
// second check to protect against raise condition
if (!getStore().plugins.panels[pluginId]) {
dispatch(panelPluginLoaded(plugin));
}
}
return plugin;
};
}
export const loadPluginDashboards = config.pluginAdminEnabled ? loadPluginDashboardsNew : loadPluginDashboardsOriginal;
export const loadPanelPlugin = config.pluginAdminEnabled ? loadPanelPluginNew : loadPanelPluginOriginal;

View File

@ -1,152 +0,0 @@
import { Reducer, AnyAction } from '@reduxjs/toolkit';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { PluginsState } from '../../../types';
import {
initialState,
pluginDashboardsLoad,
pluginDashboardsLoaded,
pluginsLoaded,
pluginsReducer,
setPluginsSearchQuery,
} from './reducers';
import { PluginMetaInfo, PluginType } from '@grafana/data';
// Mock the config to enable the old version of the plugins page
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
const mockedRuntime = { ...original };
mockedRuntime.config.pluginAdminEnabled = false;
return mockedRuntime;
});
describe('pluginsReducer', () => {
describe('when pluginsLoaded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, { ...initialState })
.whenActionIsDispatched(
pluginsLoaded([
{
id: 'some-id',
baseUrl: 'some-url',
module: 'some module',
name: 'Some Plugin',
type: PluginType.app,
info: {} as PluginMetaInfo,
},
])
)
.thenStateShouldEqual({
...initialState,
hasFetched: true,
plugins: [
{
baseUrl: 'some-url',
id: 'some-id',
info: {} as PluginMetaInfo,
module: 'some module',
name: 'Some Plugin',
type: PluginType.app,
},
],
errors: [],
});
});
});
describe('when setPluginsSearchQuery is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, { ...initialState })
.whenActionIsDispatched(setPluginsSearchQuery('A query'))
.thenStateShouldEqual({
...initialState,
searchQuery: 'A query',
});
});
});
describe('when pluginDashboardsLoad is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, {
...initialState,
dashboards: [
{
dashboardId: 1,
title: 'Some Dash',
description: 'Some Desc',
folderId: 2,
imported: false,
importedRevision: 1,
importedUri: 'some-uri',
importedUrl: 'some-url',
path: 'some/path',
pluginId: 'some-plugin-id',
removed: false,
revision: 22,
slug: 'someSlug',
},
],
})
.whenActionIsDispatched(pluginDashboardsLoad())
.thenStateShouldEqual({
...initialState,
dashboards: [],
isLoadingPluginDashboards: true,
});
});
});
describe('when pluginDashboardsLoad is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, {
...initialState,
isLoadingPluginDashboards: true,
})
.whenActionIsDispatched(
pluginDashboardsLoaded([
{
dashboardId: 1,
title: 'Some Dash',
description: 'Some Desc',
folderId: 2,
imported: false,
importedRevision: 1,
importedUri: 'some-uri',
importedUrl: 'some-url',
path: 'some/path',
pluginId: 'some-plugin-id',
removed: false,
revision: 22,
slug: 'someSlug',
},
])
)
.thenStateShouldEqual({
...initialState,
dashboards: [
{
dashboardId: 1,
title: 'Some Dash',
description: 'Some Desc',
folderId: 2,
imported: false,
importedRevision: 1,
importedUri: 'some-uri',
importedUrl: 'some-url',
path: 'some/path',
pluginId: 'some-plugin-id',
removed: false,
revision: 22,
slug: 'someSlug',
},
],
isLoadingPluginDashboards: false,
});
});
});
});

View File

@ -1,61 +0,0 @@
import { AnyAction, createSlice, PayloadAction, Reducer } from '@reduxjs/toolkit';
import { PluginMeta, PanelPlugin, PluginError } from '@grafana/data';
import { PluginsState } from 'app/types';
import { config } from 'app/core/config';
import { reducer as pluginCatalogReducer } from '../admin/state/reducer';
import { PluginDashboard } from '../../../types/plugins';
export const initialState: PluginsState = {
plugins: [],
errors: [],
searchQuery: '',
hasFetched: false,
dashboards: [],
isLoadingPluginDashboards: false,
panels: {},
};
const pluginsSlice = createSlice({
name: 'plugins',
initialState,
reducers: {
pluginsLoaded: (state, action: PayloadAction<PluginMeta[]>) => {
state.hasFetched = true;
state.plugins = action.payload;
},
pluginsErrorsLoaded: (state, action: PayloadAction<PluginError[]>) => {
state.errors = action.payload;
},
setPluginsSearchQuery: (state, action: PayloadAction<string>) => {
state.searchQuery = action.payload;
},
pluginDashboardsLoad: (state, action: PayloadAction<undefined>) => {
state.isLoadingPluginDashboards = true;
state.dashboards = [];
},
pluginDashboardsLoaded: (state, action: PayloadAction<PluginDashboard[]>) => {
state.isLoadingPluginDashboards = false;
state.dashboards = action.payload;
},
panelPluginLoaded: (state, action: PayloadAction<PanelPlugin>) => {
state.panels[action.payload.meta!.id] = action.payload;
},
},
});
export const {
pluginsLoaded,
pluginsErrorsLoaded,
pluginDashboardsLoad,
pluginDashboardsLoaded,
setPluginsSearchQuery,
panelPluginLoaded,
} = pluginsSlice.actions;
export const pluginsReducer: Reducer<PluginsState, AnyAction> = config.pluginAdminEnabled
? ((pluginCatalogReducer as unknown) as Reducer<PluginsState, AnyAction>)
: pluginsSlice.reducer;
export default {
plugins: pluginsReducer,
};

View File

@ -1,31 +0,0 @@
import { getPlugins, getPluginsSearchQuery } from './selectors';
import { initialState } from './reducers';
import { getMockPlugins } from '../__mocks__/pluginMocks';
describe('Selectors', () => {
const mockState = { ...initialState };
it('should return search query', () => {
mockState.searchQuery = 'test';
const query = getPluginsSearchQuery(mockState);
expect(query).toEqual(mockState.searchQuery);
});
it('should return plugins', () => {
mockState.plugins = getMockPlugins(5);
mockState.searchQuery = '';
const plugins = getPlugins(mockState);
expect(plugins).toEqual(mockState.plugins);
});
it('should filter plugins', () => {
mockState.searchQuery = 'plugin-1';
const plugins = getPlugins(mockState);
expect(plugins.length).toEqual(1);
});
});

View File

@ -1,19 +0,0 @@
import { PluginsState } from 'app/types/plugins';
export const getPlugins = (state: PluginsState) => {
const regex = new RegExp(state.searchQuery, 'i');
return state.plugins.filter((item) => {
return (
regex.test(item.name) ||
regex.test(item.info.author.name) ||
regex.test(item.type) ||
regex.test(item.info.description)
);
});
};
export const getAllPluginsErrors = (state: PluginsState) => {
return state.errors;
};
export const getPluginsSearchQuery = (state: PluginsState) => state.searchQuery;

View File

@ -30,7 +30,7 @@ class TestDataSource {
constructor(public instanceSettings: DataSourceInstanceSettings) {}
}
jest.mock('./plugin_loader', () => ({
jest.mock('../plugin_loader', () => ({
importDataSourcePlugin: (meta: DataSourcePluginMeta) => {
return Promise.resolve(new DataSourcePlugin(TestDataSource as any));
},

View File

@ -1,4 +1,4 @@
import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from './pluginCacheBuster';
import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from '../pluginCacheBuster';
describe('PluginCacheBuster', () => {
const now = 12345;

View File

@ -17,7 +17,7 @@ import { SystemJS } from '@grafana/runtime';
import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data';
// Loaded after the `unmock` abve
import { importAppPlugin } from './plugin_loader';
import { importAppPlugin } from '../plugin_loader';
class MyCustomApp extends AppPlugin {
initWasCalled = false;

View File

@ -0,0 +1,29 @@
import { GrafanaPlugin, PanelPluginMeta, PluginType } from '@grafana/data';
import { getPluginSettings } from './pluginSettings';
import { importAppPlugin, importDataSourcePlugin } from './plugin_loader';
import { importPanelPluginFromMeta } from './importPanelPlugin';
export async function loadPlugin(pluginId: string): Promise<GrafanaPlugin> {
const info = await getPluginSettings(pluginId);
let result: GrafanaPlugin | undefined;
if (info.type === PluginType.app) {
result = await importAppPlugin(info);
}
if (info.type === PluginType.datasource) {
result = await importDataSourcePlugin(info);
}
if (info.type === PluginType.panel) {
const panelPlugin = await importPanelPluginFromMeta(info as PanelPluginMeta);
result = (panelPlugin as unknown) as GrafanaPlugin;
}
if (info.type === PluginType.renderer) {
result = { meta: info } as GrafanaPlugin;
}
if (!result) {
throw new Error('Unknown Plugin type: ' + info.type);
}
return result;
}

View File

@ -8,7 +8,7 @@ import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamic
import { RouteDescriptor } from '../core/navigation/types';
import { Redirect } from 'react-router-dom';
import ErrorPage from 'app/core/components/ErrorPage/ErrorPage';
import { getPluginsAdminRoutes } from 'app/features/plugins/routes';
import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes';
import { contextSrv } from 'app/core/services/context_srv';
import { getLiveRoutes } from 'app/features/live/pages/routes';
import { getAlertingRoutes } from 'app/features/alerting/routes';
@ -150,7 +150,7 @@ export function getAppRoutes(): RouteDescriptor[] {
exact: false,
// Someday * and will get a ReactRouter under that path!
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/AppRootPage')
() => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage')
),
},
{
@ -372,7 +372,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
),
},
...getPluginsAdminRoutes(),
...getPluginCatalogRoutes(),
...getLiveRoutes(),
...getAlertingRoutes(),
...extraRoutes,