mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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;
|
||||
|
@@ -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>
|
||||
|
@@ -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];
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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()}
|
||||
|
@@ -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}`} />;
|
||||
})}
|
||||
|
@@ -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>
|
||||
|
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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 {
|
||||
|
@@ -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">
|
||||
|
@@ -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' };
|
||||
|
100
public/app/features/plugins/PluginsErrorsInfo.tsx
Normal file
100
public/app/features/plugins/PluginsErrorsInfo.tsx
Normal 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)
|
||||
);
|
@@ -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
|
||||
|
@@ -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>
|
||||
`;
|
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -40,6 +40,7 @@ describe('pluginsReducer', () => {
|
||||
type: PluginType.app,
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
@@ -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[];
|
||||
|
Reference in New Issue
Block a user