Commit 4468d414 by Dominik Prokop Committed by GitHub

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>
parent d1ed163f
......@@ -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 = {}> {
......
......@@ -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',
},
};
......@@ -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;
......
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>
......
......@@ -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};
`,
}));
......@@ -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;
......
......@@ -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];
}
}
// 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>
......
......@@ -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>
);
......
......@@ -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;
}
......
......@@ -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()}
......
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}`} />;
})}
......
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>
......
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();
afterEach(() => {
errorsReturnMock = [];
});
expect(wrapper).toMatchSnapshot();
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', () => {
const wrapper = setup({
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()
);
});
});
});
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();
}
async fetchPlugins() {
await this.props.loadPlugins();
}
export const PluginListPage: React.FC<Props> = ({
hasFetched,
navModel,
plugins,
setPluginsSearchQuery,
searchQuery,
loadPlugins,
}) => {
useAsync(async () => {
loadPlugins();
}, [loadPlugins]);
render() {
const { hasFetched, navModel, plugins, setPluginsSearchQuery, searchQuery } = this.props;
const linkButton = {
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
title: 'Find more plugins on Grafana.com',
};
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}>
<>
<OrgActionBar
searchQuery={searchQuery}
setSearchQuery={query => setPluginsSearchQuery(query)}
linkButton={linkButton}
target="_blank"
/>
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 {
......
......@@ -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 can’t 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">
......
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' };
......
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)
);
......@@ -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
......
......@@ -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
......
// 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>
`;
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));
};
}
......
......@@ -40,6 +40,7 @@ describe('pluginsReducer', () => {
type: PluginType.app,
},
],
errors: [],
});
});
});
......
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,
......
......@@ -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;
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[];
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment