Plugin signing: UI information (#28469)

* first pass

* return list

* types and cleanup

* add to plugin page and add styles

* update comment

* update comment

* fix component path

* simplify error component

* simplify error struct

* fix tests

* don't export and fix string()

* update naming

* remove frontend

* introduce phantom loader

* track single error

* remove error from base

* remove unused struct

* remove unnecessary filter

* add errors endpoint

* Update set log to use id field

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* skip adding BE plugins

* remove errs from plugin + ds list

* remove unnecessary fields

* add signature state to panels

* Fetch plugins errors

* grafana/ui component tweaks

* DS Picker - add unsigned badge

* VizPicker - add unsigned badge

* PluginSignatureBadge tweaks

* Plugins list - add signatures info box

* New datasource page - add signatures info box

* Plugin page - add signatures info box

* Fix test

* Do not show Core label in viz picker

* Update public/app/features/plugins/PluginsErrorsInfo.tsx

Co-authored-by: Torkel Ödegaard <torkel@grafana.org>

* Update public/app/features/plugins/PluginListPage.test.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/plugins/PluginListPage.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update public/app/features/datasources/NewDataSourcePage.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Review comments 1

* Review comments 2

* Update public/app/features/plugins/PluginsErrorsInfo.tsx

* Update public/app/features/plugins/PluginPage.tsx

* Prettier fix

* remove stale backend code

* Docs issues fix

Co-authored-by: Will Browne <will.browne@grafana.com>
Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.org>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Dominik Prokop
2020-10-27 13:08:08 +01:00
committed by GitHub
parent d1ed163fc6
commit 4468d41417
26 changed files with 426 additions and 163 deletions

View File

@@ -2,12 +2,14 @@ import { ComponentClass } from 'react';
import { KeyValue } from './data';
import { LiveChannelSupport } from './live';
/** Describes plugins life cycle status */
export enum PluginState {
alpha = 'alpha', // Only included it `enable_alpha` is true
alpha = 'alpha', // Only included if `enable_alpha` config option is true
beta = 'beta', // Will show a warning banner
deprecated = 'deprecated', // Will continue to work -- but not show up in the options to add
}
/** Describes {@link https://grafana.com/docs/grafana/latest/plugins | type of plugin} */
export enum PluginType {
panel = 'panel',
datasource = 'datasource',
@@ -15,12 +17,26 @@ export enum PluginType {
renderer = 'renderer',
}
/** Describes status of {@link https://grafana.com/docs/grafana/latest/plugins/plugin-signature-verification/ | plugin signature} */
export enum PluginSignatureStatus {
internal = 'internal', // core plugin, no signature
valid = 'valid', // signed and accurate MANIFEST
invalid = 'invalid', // invalid signature
modified = 'modified', // valid signature, but content mismatch
unsigned = 'unsigned', // no MANIFEST file
missing = 'missing', // missing signature file
}
/** Describes error code returned from Grafana plugins API call */
export enum PluginErrorCode {
missingSignature = 'signatureMissing',
invalidSignature = 'signatureInvalid',
modifiedSignature = 'signatureModified',
}
/** Describes error returned from Grafana plugins API call */
export interface PluginError {
errorCode: PluginErrorCode;
pluginId: string;
}
export interface PluginMeta<T extends KeyValue = {}> {

View File

@@ -135,4 +135,14 @@ export const Pages = {
SoloPanel: {
url: (page: string) => `/d-solo/${page}`,
},
PluginsList: {
page: 'Plugins list page',
list: 'Plugins list',
listItem: 'Plugins list item',
signatureErrorNotice: 'Unsigned plugins notice',
},
PluginPage: {
page: 'Plugin page',
signatureInfo: 'Plugin signature info',
},
};

View File

@@ -5,6 +5,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { useTheme } from '../../themes';
import { Icon } from '../Icon/Icon';
import { IconName } from '../../types/icon';
import { getColorsFromSeverity } from '../../utils/colors';
export type AlertVariant = 'success' | 'warning' | 'error' | 'info';
@@ -76,21 +77,11 @@ export const Alert: FC<Props> = ({
};
const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean) => {
const { redBase, redShade, greenBase, greenShade, blue80, blue77, white } = theme.palette;
const backgrounds = {
error: css`
background: linear-gradient(90deg, ${redBase}, ${redShade});
`,
warning: css`
background: linear-gradient(90deg, ${redBase}, ${redShade});
`,
info: css`
background: linear-gradient(100deg, ${blue80}, ${blue77});
`,
success: css`
background: linear-gradient(100deg, ${greenBase}, ${greenShade});
`,
};
const { white } = theme.palette;
const severityColors = getColorsFromSeverity(severity, theme);
const background = css`
background: linear-gradient(90deg, ${severityColors[0]}, ${severityColors[0]});
`;
return {
container: css`
@@ -106,7 +97,7 @@ const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean
display: flex;
flex-direction: row;
align-items: center;
${backgrounds[severity]}
${background}
`,
icon: css`
padding: 0 ${theme.spacing.md} 0 0;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { HTMLAttributes } from 'react';
import { Icon } from '../Icon/Icon';
import { useTheme } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes/stylesFactory';
@@ -6,23 +6,23 @@ import { IconName } from '../../types';
import { Tooltip } from '../Tooltip/Tooltip';
import { getColorForTheme, GrafanaTheme } from '@grafana/data';
import tinycolor from 'tinycolor2';
import { css } from 'emotion';
import { HorizontalGroup } from '..';
import { css, cx } from 'emotion';
import { HorizontalGroup } from '../Layout/Layout';
export type BadgeColor = 'blue' | 'red' | 'green' | 'orange' | 'purple';
export interface BadgeProps {
export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
text: string;
color: BadgeColor;
icon?: IconName;
tooltip?: string;
}
export const Badge = React.memo<BadgeProps>(({ icon, color, text, tooltip }) => {
export const Badge = React.memo<BadgeProps>(({ icon, color, text, tooltip, className, ...otherProps }) => {
const theme = useTheme();
const styles = getStyles(theme, color);
const badge = (
<div className={styles.wrapper}>
<div className={cx(styles.wrapper, className)} {...otherProps}>
<HorizontalGroup align="center" spacing="xs">
{icon && <Icon name={icon} size="sm" />}
<span>{text}</span>

View File

@@ -7,13 +7,22 @@ import { IconButton } from '../IconButton/IconButton';
import { HorizontalGroup } from '../Layout/Layout';
import panelArtDark from './panelArt_dark.svg';
import panelArtLight from './panelArt_light.svg';
import { AlertVariant } from '../Alert/Alert';
import { getColorsFromSeverity } from '../../utils/colors';
export interface InfoBoxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
children: React.ReactNode;
/** Title of the box */
title?: string | JSX.Element;
/** Url of the read more link */
url?: string;
/** Text of the read more link */
urlTitle?: string;
/** Indicates whether or not box should be rendered with Grafana branding background */
branded?: boolean;
/** Color variant of the box */
severity?: AlertVariant;
/** Call back to be performed when box is dismissed */
onDismiss?: () => void;
}
@@ -24,9 +33,9 @@ export interface InfoBoxProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
*/
export const InfoBox = React.memo(
React.forwardRef<HTMLDivElement, InfoBoxProps>(
({ title, className, children, branded, url, urlTitle, onDismiss, ...otherProps }, ref) => {
({ title, className, children, branded, url, urlTitle, onDismiss, severity = 'info', ...otherProps }, ref) => {
const theme = useTheme();
const styles = getInfoBoxStyles(theme);
const styles = getInfoBoxStyles(theme, severity);
const wrapperClassName = branded ? cx(styles.wrapperBranded, className) : cx(styles.wrapper, className);
return (
@@ -49,18 +58,15 @@ export const InfoBox = React.memo(
)
);
const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme) => ({
const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme, severity: AlertVariant) => ({
wrapper: css`
position: relative;
padding: ${theme.spacing.md};
background-color: ${theme.colors.bg2};
border-top: 3px solid ${theme.palette.blue80};
border-top: 3px solid ${getColorsFromSeverity(severity, theme)[0]};
margin-bottom: ${theme.spacing.md};
flex-grow: 1;
ul {
padding-left: ${theme.spacing.lg};
}
color: ${theme.colors.textSemiWeak};
code {
@include font-family-monospace();
@@ -109,5 +115,6 @@ const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme) => ({
display: inline-block;
margin-top: ${theme.spacing.md};
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textSemiWeak};
`,
}));

View File

@@ -20,7 +20,7 @@ export interface SelectCommonProps<T> {
filterOption?: (option: SelectableValue, searchQuery: string) => boolean;
/** Function for formatting the text that is displayed when creating a new value*/
formatCreateLabel?: (input: string) => string;
getOptionLabel?: (item: SelectableValue<T>) => string;
getOptionLabel?: (item: SelectableValue<T>) => React.ReactNode;
getOptionValue?: (item: SelectableValue<T>) => string;
inputValue?: string;
invalid?: boolean;

View File

@@ -6,6 +6,8 @@ import zip from 'lodash/zip';
import tinycolor from 'tinycolor2';
import lightTheme from '../themes/light';
import darkTheme from '../themes/dark';
import { GrafanaTheme } from '@grafana/data';
import { AlertVariant } from '../components/Alert/Alert';
export const PALETTE_ROWS = 4;
export const PALETTE_COLUMNS = 14;
@@ -101,3 +103,21 @@ export function getTextColorForBackground(color: string) {
}
export let sortedColors = sortColorsByHue(colors);
/**
* Returns colors used for severity color coding. Use for single color retrievel(0 index) or gradient definition
* @internal
**/
export function getColorsFromSeverity(severity: AlertVariant, theme: GrafanaTheme): [string, string] {
switch (severity) {
case 'error':
case 'warning':
return [theme.palette.redBase, theme.palette.redShade];
case 'info':
return [theme.palette.blue80, theme.palette.blue77];
case 'success':
return [theme.palette.greenBase, theme.palette.greenShade];
default:
return [theme.palette.blue80, theme.palette.blue77];
}
}

View File

@@ -1,5 +1,5 @@
// Libraries
import React, { Component } from 'react';
import React, { Component, HTMLAttributes } from 'react';
import { getTitleFromNavModel } from 'app/core/selectors/navModel';
// Components
@@ -11,7 +11,7 @@ import { NavModel } from '@grafana/data';
import { isEqual } from 'lodash';
import { Branding } from '../Branding/Branding';
interface Props {
interface Props extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
navModel: NavModel;
}
@@ -44,13 +44,13 @@ class Page extends Component<Props> {
}
render() {
const { navModel } = this.props;
const { navModel, children, ...otherProps } = this.props;
return (
<div className="page-scrollbar-wrapper">
<div {...otherProps} className="page-scrollbar-wrapper">
<CustomScrollbar autoHeightMin={'100%'} className="custom-scrollbar--page">
<div className="page-scrollbar-content">
<PageHeader model={navModel} />
{this.props.children}
{children}
<Footer />
</div>
</CustomScrollbar>

View File

@@ -2,9 +2,10 @@
import React, { PureComponent } from 'react';
// Components
import { Select } from '@grafana/ui';
import { HorizontalGroup, Select } from '@grafana/ui';
import { SelectableValue, DataSourceSelectItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { isUnsignedPluginSignature, PluginSignatureBadge } from '../../../features/plugins/PluginSignatureBadge';
export interface Props {
onChange: (ds: DataSourceSelectItem) => void;
@@ -57,6 +58,7 @@ export class DataSourcePicker extends PureComponent<Props> {
value: ds.name,
label: ds.name,
imgUrl: ds.meta.info.logos.small,
meta: ds.meta,
}));
const value = current && {
@@ -65,6 +67,7 @@ export class DataSourcePicker extends PureComponent<Props> {
imgUrl: current.meta.info.logos.small,
loading: showLoading,
hideText: hideTextValue,
meta: current.meta,
};
return (
@@ -85,6 +88,16 @@ export class DataSourcePicker extends PureComponent<Props> {
noOptionsMessage="No datasources found"
value={value}
invalid={invalid}
getOptionLabel={o => {
if (isUnsignedPluginSignature(o.meta.signature) && o !== value) {
return (
<HorizontalGroup align="center" justify="space-between">
<span>{o.label}</span> <PluginSignatureBadge status={o.meta.signature} />
</HorizontalGroup>
);
}
return o.label || '';
}}
/>
</div>
);

View File

@@ -3,6 +3,7 @@ import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data';
import { Badge, BadgeProps, styleMixins, stylesFactory, useTheme } from '@grafana/ui';
import { css, cx } from 'emotion';
import { selectors } from '@grafana/e2e-selectors';
import { isUnsignedPluginSignature, PluginSignatureBadge } from '../../plugins/PluginSignatureBadge';
interface Props {
isCurrent: boolean;
@@ -135,6 +136,10 @@ interface PanelPluginBadgeProps {
const PanelPluginBadge: React.FC<PanelPluginBadgeProps> = ({ plugin }) => {
const display = getPanelStateBadgeDisplayModel(plugin);
if (isUnsignedPluginSignature(plugin.signature)) {
return <PluginSignatureBadge status={plugin.signature} />;
}
if (plugin.state !== PluginState.deprecated && plugin.state !== PluginState.alpha) {
return null;
}

View File

@@ -13,6 +13,7 @@ import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { setDataSourceTypeSearchQuery } from './state/reducers';
import { PluginSignatureBadge } from '../plugins/PluginSignatureBadge';
import { Card } from 'app/core/components/Card/Card';
import { PluginsErrorsInfo } from '../plugins/PluginsErrorsInfo';
export interface Props {
navModel: NavModel;
@@ -98,6 +99,17 @@ class NewDataSourcePage extends PureComponent<Props> {
<div className="page-action-bar__spacer" />
<LinkButton href="datasources">Cancel</LinkButton>
</div>
{!searchQuery && (
<PluginsErrorsInfo>
<>
<br />
<p>
Note that <strong>unsigned front-end datasource plugins</strong> are still usable, but this is subject
to change in the upcoming releases of Grafana
</p>
</>
</PluginsErrorsInfo>
)}
<div>
{searchQuery && this.renderPlugins(plugins)}
{!searchQuery && this.renderCategories()}

View File

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

View File

@@ -1,6 +1,7 @@
import React, { FC } from 'react';
import { PluginMeta } from '@grafana/data';
import { PluginSignatureBadge } from './PluginSignatureBadge';
import { selectors } from '@grafana/e2e-selectors';
interface Props {
plugin: PluginMeta;
@@ -10,7 +11,7 @@ const PluginListItem: FC<Props> = props => {
const { plugin } = props;
return (
<li className="card-item-wrapper">
<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>

View File

@@ -1,11 +1,27 @@
import React from 'react';
import { shallow } from 'enzyme';
import { PluginListPage, Props } from './PluginListPage';
import { NavModel, PluginMeta } from '@grafana/data';
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', () => ({
...(jest.requireActual('@grafana/runtime') as object),
getBackendSrv: () => ({
get: () => {
return errorsReturnMock as any;
},
}),
}));
const setup = (propOverrides?: object) => {
const store = configureStore();
const props: Props = {
navModel: {
main: {
@@ -24,21 +40,47 @@ const setup = (propOverrides?: object) => {
Object.assign(props, propOverrides);
return shallow(<PluginListPage {...props} />);
return render(
<Provider store={store}>
<PluginListPage {...props} />
</Provider>
);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
afterEach(() => {
errorsReturnMock = [];
});
it('should render list', () => {
const wrapper = setup({
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();
});
});
expect(wrapper).toMatchSnapshot();
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,4 +1,4 @@
import React, { PureComponent } from 'react';
import React from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import Page from 'app/core/components/Page/Page';
@@ -10,6 +10,9 @@ import { getPlugins, getPluginsSearchQuery } from './state/selectors';
import { NavModel, PluginMeta } from '@grafana/data';
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';
export interface Props {
navModel: NavModel;
@@ -20,40 +23,49 @@ export interface Props {
setPluginsSearchQuery: typeof setPluginsSearchQuery;
}
export class PluginListPage extends PureComponent<Props> {
componentDidMount() {
this.fetchPlugins();
}
export const PluginListPage: React.FC<Props> = ({
hasFetched,
navModel,
plugins,
setPluginsSearchQuery,
searchQuery,
loadPlugins,
}) => {
useAsync(async () => {
loadPlugins();
}, [loadPlugins]);
async fetchPlugins() {
await this.props.loadPlugins();
}
const linkButton = {
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
title: 'Find more plugins on Grafana.com',
};
render() {
const { hasFetched, navModel, plugins, setPluginsSearchQuery, searchQuery } = this.props;
return (
<Page navModel={navModel} aria-label={selectors.pages.PluginsList.page}>
<Page.Contents isLoading={!hasFetched}>
<>
<OrgActionBar
searchQuery={searchQuery}
setSearchQuery={query => setPluginsSearchQuery(query)}
linkButton={linkButton}
target="_blank"
/>
const linkButton = {
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
title: 'Find more plugins on Grafana.com',
};
return (
<Page navModel={navModel}>
<Page.Contents isLoading={!hasFetched}>
<>
<OrgActionBar
searchQuery={searchQuery}
setSearchQuery={query => setPluginsSearchQuery(query)}
linkButton={linkButton}
target="_blank"
/>
{hasFetched && plugins && plugins && <PluginList plugins={plugins} />}
</>
</Page.Contents>
</Page>
);
}
}
<PluginsErrorsInfo>
<>
<br />
<p>
Note that <strong>unsigned front-end datasource and panel plugins</strong> are still usable, but this is
subject to change in the upcoming releases of Grafana
</p>
</>
</PluginsErrorsInfo>
{hasFetched && plugins && <PluginList plugins={plugins} />}
</>
</Page.Contents>
</Page>
);
};
function mapStateToProps(state: StoreState) {
return {

View File

@@ -14,11 +14,12 @@ import {
PluginIncludeType,
PluginMeta,
PluginMetaInfo,
PluginSignatureStatus,
PluginType,
UrlQueryMap,
} from '@grafana/data';
import { AppNotificationSeverity, CoreEvents, StoreState } from 'app/types';
import { Alert, Tooltip } from '@grafana/ui';
import { Alert, InfoBox, Tooltip } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { getPluginSettings } from './PluginSettingsCache';
@@ -30,6 +31,9 @@ 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';
import { PluginSignatureBadge } from './PluginSignatureBadge';
import { selectors } from '@grafana/e2e-selectors';
export function getLoadingNav(): NavModel {
const node = {
@@ -102,6 +106,7 @@ class PluginPage extends PureComponent<Props, State> {
const { appSubUrl } = config;
const plugin = await loadPlugin(pluginId);
if (!plugin) {
this.setState({
loading: false,
@@ -293,13 +298,48 @@ class PluginPage extends PureComponent<Props, State> {
);
}
renderPluginNotice() {
const { plugin } = this.state;
if (!plugin) {
return null;
}
if (plugin.meta.signature === PluginSignatureStatus.internal) {
return null;
}
return (
<InfoBox
aria-label={selectors.pages.PluginPage.signatureInfo}
severity={plugin.meta.signature !== PluginSignatureStatus.valid ? 'warning' : 'info'}
urlTitle="Read more about plugins signing"
url="https://grafana.com/docs/grafana/latest/plugins/plugin-signature-verification/"
>
<p>
<PluginSignatureBadge
status={plugin.meta.signature}
className={css`
margin-top: 0;
`}
/>
</p>
<p>
Grafana Labs checks each plugin to verify that it has a valid digital signature. Plugin signature verification
is part of our security measure to ensure plugins are safe and trustworthy. Grafana Labs cant guarantee the
integrity of this unsigned plugin. Ask the plugin author to request it to be signed.
</p>
</InfoBox>
);
}
render() {
const { loading, nav, plugin } = this.state;
const { $contextSrv } = this.props;
const isAdmin = $contextSrv.hasRole('Admin');
return (
<Page navModel={nav}>
<Page navModel={nav} aria-label={selectors.pages.PluginPage.page}>
<Page.Contents isLoading={loading}>
{plugin && (
<div className="sidebar-container">
@@ -316,6 +356,7 @@ class PluginPage extends PureComponent<Props, State> {
}
/>
)}
{this.renderPluginNotice()}
{this.renderBody()}
</div>
<aside className="page-sidebar">

View File

@@ -1,16 +1,41 @@
import React from 'react';
import React, { HTMLAttributes } from 'react';
import { Badge, BadgeProps } from '@grafana/ui';
import { PluginSignatureStatus } from '@grafana/data';
import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data';
interface Props {
interface Props extends HTMLAttributes<HTMLDivElement> {
status?: PluginSignatureStatus;
}
export const PluginSignatureBadge: React.FC<Props> = ({ status }) => {
export const PluginSignatureBadge: React.FC<Props> = ({ status, ...otherProps }) => {
const display = getSignatureDisplayModel(status);
return <Badge text={display.text} color={display.color} icon={display.icon} tooltip={display.tooltip} />;
return (
<Badge
text={display.text}
color={display.color as any}
icon={display.icon}
tooltip={display.tooltip}
{...otherProps}
/>
);
};
export function isUnsignedPluginSignature(signature?: PluginSignatureStatus) {
return signature && signature !== PluginSignatureStatus.valid && signature !== PluginSignatureStatus.internal;
}
export function mapPluginErrorCodeToSignatureStatus(code: PluginErrorCode) {
switch (code) {
case PluginErrorCode.invalidSignature:
return PluginSignatureStatus.invalid;
case PluginErrorCode.missingSignature:
return PluginSignatureStatus.missing;
case PluginErrorCode.modifiedSignature:
return PluginSignatureStatus.modified;
default:
return PluginSignatureStatus.missing;
}
}
function getSignatureDisplayModel(signature?: PluginSignatureStatus): BadgeProps {
if (!signature) {
signature = PluginSignatureStatus.invalid;
@@ -23,18 +48,25 @@ function getSignatureDisplayModel(signature?: PluginSignatureStatus): BadgeProps
return { text: 'Signed', icon: 'lock', color: 'green', tooltip: 'Signed and verified plugin' };
case PluginSignatureStatus.invalid:
return {
text: 'Invalid',
text: 'Invalid signature',
icon: 'exclamation-triangle',
color: 'red',
tooltip: 'Invalid plugin signature',
};
case PluginSignatureStatus.modified:
return {
text: 'Modified',
text: 'Modified signature',
icon: 'exclamation-triangle',
color: 'red',
tooltip: 'Valid signature but content has been modified',
};
case PluginSignatureStatus.missing:
return {
text: 'Missing signture',
icon: 'exclamation-triangle',
color: 'red',
tooltip: 'Missing plugin signature',
};
}
return { text: 'Unsigned', icon: 'exclamation-triangle', color: 'red', tooltip: 'Unsigned external plugin' };

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { HorizontalGroup, InfoBox, List, useTheme } from '@grafana/ui';
import { mapPluginErrorCodeToSignatureStatus, PluginSignatureBadge } from './PluginSignatureBadge';
import { StoreState } from '../../types';
import { getAllPluginsErrors } from './state/selectors';
import { loadPlugins, loadPluginsErrors } from './state/actions';
import useAsync from 'react-use/lib/useAsync';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { hot } from 'react-hot-loader';
import { PluginError } from '@grafana/data';
import { css } from 'emotion';
interface ConnectedProps {
errors: PluginError[];
}
interface DispatchProps {
loadPluginsErrors: typeof loadPluginsErrors;
}
interface OwnProps {
children?: React.ReactNode;
}
type PluginsErrorsInfoProps = ConnectedProps & DispatchProps & OwnProps;
export const PluginsErrorsInfoUnconnected: React.FC<PluginsErrorsInfoProps> = ({
loadPluginsErrors,
errors,
children,
}) => {
const theme = useTheme();
const { loading } = useAsync(async () => {
await loadPluginsErrors();
}, [loadPlugins]);
if (loading || errors.length === 0) {
return null;
}
return (
<InfoBox
aria-label={selectors.pages.PluginsList.signatureErrorNotice}
severity="warning"
urlTitle="Read more about plugin signing"
url="https://grafana.com/docs/grafana/latest/plugins/plugin-signature-verification/"
>
<div>
<p>
We have encountered{' '}
<a href="https://grafana.com/docs/grafana/latest/developers/plugins/backend/" target="_blank">
data source backend plugins
</a>{' '}
that are unsigned. Grafana Labs cannot guarantee the integrity of unsigned plugins and recommends using signed
plugins only.
</p>
The following plugins are disabled and not shown in the list below:
<List
items={errors}
className={css`
list-style-type: circle;
`}
renderItem={e => (
<div
className={css`
margin-top: ${theme.spacing.sm};
`}
>
<HorizontalGroup spacing="sm" justify="flex-start" align="center">
<strong>{e.pluginId}</strong>
<PluginSignatureBadge
status={mapPluginErrorCodeToSignatureStatus(e.errorCode)}
className={css`
margin-top: 0;
`}
/>
</HorizontalGroup>
</div>
)}
/>
{children}
</div>
</InfoBox>
);
};
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state: StoreState) => {
return {
errors: getAllPluginsErrors(state.plugins),
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
loadPluginsErrors,
};
export const PluginsErrorsInfo = hot(module)(
connect(mapStateToProps, mapDispatchToProps)(PluginsErrorsInfoUnconnected)
);

View File

@@ -5,6 +5,7 @@ exports[`Render should render component 1`] = `
className="card-section card-list-layout-list"
>
<ol
aria-label="Plugins list"
className="card-list"
>
<PluginListItem

View File

@@ -2,6 +2,7 @@
exports[`Render should render component 1`] = `
<li
aria-label="Plugins list item"
className="card-item-wrapper"
>
<a
@@ -49,6 +50,7 @@ exports[`Render should render component 1`] = `
exports[`Render should render has plugin section 1`] = `
<li
aria-label="Plugins list item"
className="card-item-wrapper"
>
<a

View File

@@ -1,66 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Plugins",
},
}
}
>
<PageContents
isLoading={true}
>
<OrgActionBar
linkButton={
Object {
"href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
"title": "Find more plugins on Grafana.com",
}
}
searchQuery=""
setSearchQuery={[Function]}
target="_blank"
/>
</PageContents>
</Page>
`;
exports[`Render should render list 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Plugins",
},
}
}
>
<PageContents
isLoading={false}
>
<OrgActionBar
linkButton={
Object {
"href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
"title": "Find more plugins on Grafana.com",
}
}
searchQuery=""
setSearchQuery={[Function]}
target="_blank"
/>
<PluginList
plugins={Array []}
/>
</PageContents>
</Page>
`;

View File

@@ -1,13 +1,26 @@
import { getBackendSrv } from '@grafana/runtime';
import { PanelPlugin } from '@grafana/data';
import { ThunkResult } from 'app/types';
import { pluginDashboardsLoad, pluginDashboardsLoaded, pluginsLoaded, panelPluginLoaded } from './reducers';
import {
pluginDashboardsLoad,
pluginDashboardsLoaded,
pluginsLoaded,
panelPluginLoaded,
pluginsErrorsLoaded,
} from './reducers';
import { importPanelPlugin } from 'app/features/plugins/plugin_loader';
export function loadPlugins(): ThunkResult<void> {
return async dispatch => {
const result = await getBackendSrv().get('api/plugins', { embedded: 0 });
dispatch(pluginsLoaded(result));
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));
};
}

View File

@@ -40,6 +40,7 @@ describe('pluginsReducer', () => {
type: PluginType.app,
},
],
errors: [],
});
});
});

View File

@@ -1,10 +1,11 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { PluginMeta, PanelPlugin } from '@grafana/data';
import { PluginMeta, PanelPlugin, PluginError } from '@grafana/data';
import { PluginsState } from 'app/types';
import { PluginDashboard } from '../../../types/plugins';
export const initialState: PluginsState = {
plugins: [],
errors: [],
searchQuery: '',
hasFetched: false,
dashboards: [],
@@ -20,6 +21,9 @@ const pluginsSlice = createSlice({
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;
},
@@ -39,6 +43,7 @@ const pluginsSlice = createSlice({
export const {
pluginsLoaded,
pluginsErrorsLoaded,
pluginDashboardsLoad,
pluginDashboardsLoaded,
setPluginsSearchQuery,

View File

@@ -7,5 +7,8 @@ export const getPlugins = (state: PluginsState) => {
return regex.test(item.name) || regex.test(item.info.author.name) || regex.test(item.info.description);
});
};
export const getAllPluginsErrors = (state: PluginsState) => {
return state.errors;
};
export const getPluginsSearchQuery = (state: PluginsState) => state.searchQuery;

View File

@@ -1,4 +1,4 @@
import { PluginMeta } from '@grafana/data';
import { PluginError, PluginMeta } from '@grafana/data';
import { PanelPlugin } from '@grafana/data';
import { TemplateSrv } from '@grafana/runtime';
@@ -24,6 +24,7 @@ export interface PanelPluginsIndex {
export interface PluginsState {
plugins: PluginMeta[];
errors: PluginError[];
searchQuery: string;
hasFetched: boolean;
dashboards: PluginDashboard[];