The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/plugins/PluginPage.tsx

578 lines
16 KiB

// Libraries
import React, { PureComponent } from 'react';
import { capitalize, find } from 'lodash';
// Types
import {
AppPlugin,
GrafanaPlugin,
GrafanaTheme2,
NavModel,
NavModelItem,
PluginDependencies,
PluginInclude,
PluginIncludeType,
PluginMeta,
PluginMetaInfo,
PluginSignatureStatus,
PluginSignatureType,
PluginType,
UrlQueryMap,
PanelPluginMeta,
} from '@grafana/data';
import { AppNotificationSeverity } from 'app/types';
import { Alert, LinkButton, PluginSignatureBadge, Tooltip, Badge, useStyles2, Icon } 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/core/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 can’t 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 function loadPlugin(pluginId: string): Promise<GrafanaPlugin> {
return getPluginSettings(pluginId).then((info) => {
if (info.type === PluginType.app) {
return importAppPlugin(info);
}
if (info.type === PluginType.datasource) {
return importDataSourcePlugin(info);
}
if (info.type === PluginType.panel) {
return importPanelPluginFromMeta(info as PanelPluginMeta);
}
if (info.type === PluginType.renderer) {
return Promise.resolve({ meta: info } as GrafanaPlugin);
}
return Promise.reject('Unknown Plugin type: ' + info.type);
});
}
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;