Files
grafana/public/app/features/plugins/PluginPage.tsx
Jack Westbrook abdceb18da Plugins: introduce marketplace app (#33869)
* add uninstall flow

* add install flow

* small cleanup

* smaller-footprint solution

* cleanup + make bp start auto

* fix interface contract

* improve naming

* accept version arg

* ensure use of shared logger

* make installer a field

* add plugin decommissioning

* add basic error checking

* fix api docs

* making initialization idempotent

* add mutex

* fix comment

* fix test

* add test for decommission

* improve existing test

* add more test coverage

* more tests

* change test func to use read lock

* refactoring + adding test asserts

* improve purging old install flow

* improve dupe checking

* change log name

* skip over dupe scanned

* make test assertion more flexible

* remove trailing line

* fix pointer receiver name

* update comment

* add context to API

* add config flag

* add base http api test + fix update functionality

* simplify existing check

* clean up test

* refactor tests based on feedback

* add single quotes to errs

* use gcmp in tests + fix logo issue

* make plugin list testing more flexible

* address feedback

* fix API test

* fix linter

* undo preallocate

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* fix linting issue in test

* add docs placeholder

* update install notes

* Update docs/sources/plugins/marketplace.md

Co-authored-by: Marcus Olsson <marcus.olsson@hey.com>

* update access wording

* add more placeholder docs

* add link to more info

* PR feedback - improved errors, refactor, lock fix

* improve err details

* propagate plugin version errors

* don't autostart renderer

* add H1

* fix imports

* feat(bundled-plugins): introduce marketplace-app to the repo

* chore(marketplace-app): remove backend

* feat(marketplace): nav is only visible to admins

* feat(marketplace): use gnet api

* refactor(marketplace): move styles to theme2 move card concerns to card component

* feat(marketplace): introduce loaders, clean up styles

* refactor(marketplace): wip - debugging for api endpoints

* feat(grafana-ui): introduce height prop to Layout components

* refactor(marketplace): clean up org details component

* fix(marketplace): display search results without active filters

* refactor(marketplace): use grafana api for install/uninstall

* refactor(marketplace): remove pluginDir

* feat(marketplace): clean up Library page

* fix(plugins): get panel plugins from meta for newly installed plugins

* fix(marketplace): fix multiple renders of InstallControls when installing/uninstalling plugins

* refactor(marketplace): componentise the loader

* chore(marketplace): remove unsigned plugins option

* refactor(marketplace): remove includeUnsigned from codebase

* refactor(marketplace): prefer useStyles over useTheme

* docs(marketplace): remove changelog and update readme

* Apply suggestions from code review

Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* refactor(marketplace): prefer module over class for api

* refactor(marketplace): move updatePlugin to api

* refactor(marketplace): add PluginMeta to api to please TS

* feat(marketplace): always include pre-release in semver check

* chore(marketplace): remove redundant arch install code. backend handles it now

* feat(marketplace): add a link to browse page if library is empty

* refactor(marketplace): replace status with isLoading boolean for hooks

* refactor(marketplace): clean up async functions in InstallControls

* fix(marketplace): setInstalled false on successful uninstall

* include marketplace in bundled plugin test

Co-authored-by: Will Browne <will.browne@grafana.com>
Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
Co-authored-by: Marcus Olsson <marcus.olsson@hey.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
2021-05-12 21:07:37 +02:00

582 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, importPanelPluginFromMeta } from './plugin_loader';
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() {
const { location, queryParams } = this.props;
const { appSubUrl } = config;
const plugin = await loadPlugin(this.props.match.params.pluginId);
if (!plugin) {
this.setState({
loading: false,
nav: getNotFoundNav(),
});
return; // 404
}
const { defaultPage, nav } = getPluginTabsNav(
plugin,
appSubUrl,
location.pathname,
queryParams,
contextSrv.hasRole('Admin')
);
this.setState({
loading: false,
plugin,
defaultPage,
nav,
});
}
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 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;