diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 0aa96f68ed8..6d0b5f56a37 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -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 { diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 9d2558ecd46..4f7b571f4fc 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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', + }, }; diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index 3281824bba9..8ce9a52ea43 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -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 = ({ }; 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; diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx index c51f22e460a..d68c504bd26 100644 --- a/packages/grafana-ui/src/components/Badge/Badge.tsx +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -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 { text: string; color: BadgeColor; icon?: IconName; tooltip?: string; } -export const Badge = React.memo(({ icon, color, text, tooltip }) => { +export const Badge = React.memo(({ icon, color, text, tooltip, className, ...otherProps }) => { const theme = useTheme(); const styles = getStyles(theme, color); const badge = ( -
+
{icon && } {text} diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx index 0ecc6da2dfd..68503ebd94e 100644 --- a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx @@ -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, '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, */ export const InfoBox = React.memo( React.forwardRef( - ({ 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}; `, })); diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index a99cbdb6979..f9dfceac73c 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -20,7 +20,7 @@ export interface SelectCommonProps { 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) => string; + getOptionLabel?: (item: SelectableValue) => React.ReactNode; getOptionValue?: (item: SelectableValue) => string; inputValue?: string; invalid?: boolean; diff --git a/packages/grafana-ui/src/utils/colors.ts b/packages/grafana-ui/src/utils/colors.ts index 15c393c885b..cf6365557a8 100644 --- a/packages/grafana-ui/src/utils/colors.ts +++ b/packages/grafana-ui/src/utils/colors.ts @@ -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]; + } +} diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 363aec5a3b6..0838a96280d 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -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 { children: React.ReactNode; navModel: NavModel; } @@ -44,13 +44,13 @@ class Page extends Component { } render() { - const { navModel } = this.props; + const { navModel, children, ...otherProps } = this.props; return ( -
+
- {this.props.children} + {children}
diff --git a/public/app/core/components/Select/DataSourcePicker.tsx b/public/app/core/components/Select/DataSourcePicker.tsx index 8cbeceb214f..a2e9eeb4dca 100644 --- a/public/app/core/components/Select/DataSourcePicker.tsx +++ b/public/app/core/components/Select/DataSourcePicker.tsx @@ -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 { 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 { imgUrl: current.meta.info.logos.small, loading: showLoading, hideText: hideTextValue, + meta: current.meta, }; return ( @@ -85,6 +88,16 @@ export class DataSourcePicker extends PureComponent { noOptionsMessage="No datasources found" value={value} invalid={invalid} + getOptionLabel={o => { + if (isUnsignedPluginSignature(o.meta.signature) && o !== value) { + return ( + + {o.label} + + ); + } + return o.label || ''; + }} />
); diff --git a/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx b/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx index 9adba8b36e4..791aa5256ab 100644 --- a/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx +++ b/public/app/features/dashboard/panel_editor/VizTypePickerPlugin.tsx @@ -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 = ({ plugin }) => { const display = getPanelStateBadgeDisplayModel(plugin); + if (isUnsignedPluginSignature(plugin.signature)) { + return ; + } + if (plugin.state !== PluginState.deprecated && plugin.state !== PluginState.alpha) { return null; } diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index fc56529ddba..c809291b005 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -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 {
Cancel
+ {!searchQuery && ( + + <> +
+

+ Note that unsigned front-end datasource plugins are still usable, but this is subject + to change in the upcoming releases of Grafana +

+ +
+ )}
{searchQuery && this.renderPlugins(plugins)} {!searchQuery && this.renderCategories()} diff --git a/public/app/features/plugins/PluginList.tsx b/public/app/features/plugins/PluginList.tsx index 01b8b0fbcd5..7ddffba5116 100644 --- a/public/app/features/plugins/PluginList.tsx +++ b/public/app/features/plugins/PluginList.tsx @@ -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 => { return (
-
    +
      {plugins.map((plugin, index) => { return ; })} diff --git a/public/app/features/plugins/PluginListItem.tsx b/public/app/features/plugins/PluginListItem.tsx index 636efefccdc..e31f25f8bc4 100644 --- a/public/app/features/plugins/PluginListItem.tsx +++ b/public/app/features/plugins/PluginListItem.tsx @@ -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 => { const { plugin } = props; return ( -
    1. +
    2. {plugin.type}
      diff --git a/public/app/features/plugins/PluginListPage.test.tsx b/public/app/features/plugins/PluginListPage.test.tsx index 15ec19af65f..550a473127b 100644 --- a/public/app/features/plugins/PluginListPage.test.tsx +++ b/public/app/features/plugins/PluginListPage.test.tsx @@ -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(); + return render( + + + + ); }; 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() + ); + }); }); }); diff --git a/public/app/features/plugins/PluginListPage.tsx b/public/app/features/plugins/PluginListPage.tsx index b39e4de5979..8632e647bb0 100644 --- a/public/app/features/plugins/PluginListPage.tsx +++ b/public/app/features/plugins/PluginListPage.tsx @@ -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 { - componentDidMount() { - this.fetchPlugins(); - } +export const PluginListPage: React.FC = ({ + 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 ( + + + <> + 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 ( - - - <> - setPluginsSearchQuery(query)} - linkButton={linkButton} - target="_blank" - /> - {hasFetched && plugins && plugins && } - - - - ); - } -} + + <> +
      +

      + Note that unsigned front-end datasource and panel plugins are still usable, but this is + subject to change in the upcoming releases of Grafana +

      + +
      + {hasFetched && plugins && } + +
      +
      + ); +}; function mapStateToProps(state: StoreState) { return { diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx index d31687a3d02..bf29f4240a3 100644 --- a/public/app/features/plugins/PluginPage.tsx +++ b/public/app/features/plugins/PluginPage.tsx @@ -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 { const { appSubUrl } = config; const plugin = await loadPlugin(pluginId); + if (!plugin) { this.setState({ loading: false, @@ -293,13 +298,48 @@ class PluginPage extends PureComponent { ); } + renderPluginNotice() { + const { plugin } = this.state; + + if (!plugin) { + return null; + } + + if (plugin.meta.signature === PluginSignatureStatus.internal) { + return null; + } + + return ( + +

      + +

      +

      + 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. +

      +
      + ); + } + render() { const { loading, nav, plugin } = this.state; const { $contextSrv } = this.props; const isAdmin = $contextSrv.hasRole('Admin'); return ( - + {plugin && (
      @@ -316,6 +356,7 @@ class PluginPage extends PureComponent { } /> )} + {this.renderPluginNotice()} {this.renderBody()}