PluginsCatalog: adding error information about disabled plugins. (#39171)

* added errors in plugin list.

* added error to details page.

* adding badge on details page.

* added some more tests.

* Renamed to disabled and will handle the scenario in the plugin catalog.

* Update public/app/features/plugins/admin/components/PluginDetailsDisabledError.tsx

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* fixing some nits

* added missing isDisabeld to the mock.

* adding tests to verify scenarios when plugin is disabled.

* fixed issue with formatting after file changed on GH.

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Marcus Andersson 2021-09-20 09:08:00 +02:00 committed by GitHub
parent a899e9be10
commit f3002931f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 307 additions and 68 deletions

View File

@ -154,7 +154,7 @@ Since not all datasources have the same configuration settings we only have the
| maxSeries | number | Influxdb | Max number of series/tables that Grafana processes |
| httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to POST |
| customQueryParameters | string | Prometheus | Query parameters to add, as a URL-encoded string. |
| manageAlerts | boolean | Prometheus and Loki | Manage alerts via Alerting UI |
| manageAlerts | boolean | Prometheus and Loki | Manage alerts via Alerting UI |
| esVersion | string | Elasticsearch | Elasticsearch version (E.g. `7.0.0`, `7.6.1`) |
| timeField | string | Elasticsearch | Which field that should be used as timestamp |
| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |

View File

@ -109,10 +109,12 @@ allowed_groups =
```
You can also use these environment variables to configure **client_id** and **client_secret**:
```
GF_AUTH_AZUREAD_CLIENT_ID
GF_AUTH_AZUREAD_CLIENT_SECRET
```
**Note:** Ensure that the [root_url]({{< relref "../administration/configuration/#root-url" >}}) in Grafana is set in your Azure Application Reply URLs (**App** -> **Settings** -> **Reply URLs**)
### Configure allowed groups

View File

@ -31,8 +31,8 @@ The reference information that follows complements conceptual information about
## Default built-in role assignments
| Built-in role | Associated role | Description |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Built-in role | Associated role | Description |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Grafana Admin | `fixed:permissions:admin:edit`<br>`fixed:permissions:admin:read`<br>`fixed:provisioning:admin`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read`<br>`fixed:users:admin:edit`<br>`fixed:users:admin:read`<br>`fixed:users:org:edit`<br>`fixed:users:org:read`<br>`fixed:ldap:admin:edit`<br>`fixed:ldap:admin:read`<br>`fixed:server:admin:read`<br>`fixed:settings:admin:read`<br>`fixed:settings:admin:edit` | Allow access to the same resources and permissions the [Grafana server administrator]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) has by default. |
| Admin | `fixed:users:org:edit`<br>`fixed:users:org:read`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read` | Allow access to the same resources and permissions that the [Grafana organization administrator]({{< relref "../../permissions/organization_roles.md" >}}) has by default. |
| Admin | `fixed:users:org:edit`<br>`fixed:users:org:read`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read` | Allow access to the same resources and permissions that the [Grafana organization administrator]({{< relref "../../permissions/organization_roles.md" >}}) has by default. |
| Editor | `fixed:datasource:editor:read` |

View File

@ -159,6 +159,7 @@ export const Pages = {
PluginPage: {
page: 'Plugin page',
signatureInfo: 'Plugin signature info',
disabledInfo: 'Plugin disabled info',
},
PlaylistForm: {
name: 'Playlist name',

View File

@ -1,3 +1,4 @@
//go:build !windows
// +build !windows
package process

View File

@ -1,3 +1,4 @@
//go:build windows
// +build windows
package process

View File

@ -16,6 +16,7 @@ export default {
isDev: false,
isEnterprise: false,
isInstalled: false,
isDisabled: false,
name: 'Zabbix',
orgName: 'Alexander Zobnin',
popularity: 0.2093,

View File

@ -1,6 +1,7 @@
import { getBackendSrv } from '@grafana/runtime';
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
import { mergeLocalsAndRemotes, mergeLocalAndRemote } from './helpers';
import { PluginError } from '@grafana/data';
import {
PluginDetails,
Org,
@ -13,9 +14,13 @@ import {
} from './types';
export async function getCatalogPlugins(): Promise<CatalogPlugin[]> {
const [localPlugins, remotePlugins] = await Promise.all([getLocalPlugins(), getRemotePlugins()]);
const [localPlugins, remotePlugins, pluginErrors] = await Promise.all([
getLocalPlugins(),
getRemotePlugins(),
getPluginErrors(),
]);
return mergeLocalsAndRemotes(localPlugins, remotePlugins);
return mergeLocalsAndRemotes(localPlugins, remotePlugins, pluginErrors);
}
export async function getCatalogPlugin(id: string): Promise<CatalogPlugin> {
@ -68,6 +73,14 @@ async function getPlugin(slug: string): Promise<PluginDetails> {
};
}
async function getPluginErrors(): Promise<PluginError[]> {
try {
return await getBackendSrv().get(`${API_ROOT}/errors`);
} catch (error) {
return [];
}
}
async function getRemotePlugin(id: string, isInstalled: boolean): Promise<RemotePlugin | undefined> {
try {
return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`);

View File

@ -0,0 +1,23 @@
import React from 'react';
import { PluginErrorCode } from '@grafana/data';
import { Badge } from '@grafana/ui';
type Props = { error?: PluginErrorCode };
export function PluginDisabledBadge({ error }: Props): React.ReactElement {
const tooltip = errorCodeToTooltip(error);
return <Badge icon="exclamation-triangle" text="Disabled" color="red" tooltip={tooltip} />;
}
function errorCodeToTooltip(error?: PluginErrorCode): string | undefined {
switch (error) {
case PluginErrorCode.modifiedSignature:
return 'Plugin disabled due to modified content';
case PluginErrorCode.invalidSignature:
return 'Plugin disabled due to invalid plugin signature';
case PluginErrorCode.missingSignature:
return 'Plugin disabled due to missing plugin signature';
default:
return `Plugin disabled due to unkown error: ${error}`;
}
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Badge, Button, HorizontalGroup, PluginSignatureBadge, useStyles2 } from '@grafana/ui';
import { CatalogPlugin } from '../../types';
import { getBadgeColor } from './sharedStyles';
import { config } from '@grafana/runtime';
type Props = { plugin: CatalogPlugin };
export function PluginEnterpriseBadge({ plugin }: Props): React.ReactElement {
const customBadgeStyles = useStyles2(getBadgeColor);
const onClick = (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
ev.preventDefault();
window.open(
`https://grafana.com/grafana/plugins/${plugin.id}?utm_source=grafana_catalog_learn_more`,
'_blank',
'noopener,noreferrer'
);
};
if (config.licenseInfo?.hasValidLicense) {
return <Badge text="Enterprise" color="blue" />;
}
return (
<HorizontalGroup>
<PluginSignatureBadge status={plugin.signature} />
<Badge icon="lock" aria-label="lock icon" text="Enterprise" color="blue" className={customBadgeStyles} />
<Button size="sm" fill="text" icon="external-link-alt" onClick={onClick}>
Learn more
</Button>
</HorizontalGroup>
);
}

View File

@ -0,0 +1,8 @@
import React from 'react';
import { Badge, useStyles2 } from '@grafana/ui';
import { getBadgeColor } from './sharedStyles';
export function PluginInstalledBadge(): React.ReactElement {
const customBadgeStyles = useStyles2(getBadgeColor);
return <Badge text="Installed" color="orange" className={customBadgeStyles} />;
}

View File

@ -0,0 +1,3 @@
export { PluginDisabledBadge } from './PluginDisabledBadge';
export { PluginInstalledBadge } from './PluginInstallBadge';
export { PluginEnterpriseBadge } from './PluginEnterpriseBadge';

View File

@ -0,0 +1,8 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export const getBadgeColor = (theme: GrafanaTheme2) => css`
background: ${theme.colors.background.primary};
border-color: ${theme.colors.border.strong};
color: ${theme.colors.text.secondary};
`;

View File

@ -32,7 +32,7 @@ export const InstallControls = ({ plugin }: Props) => {
: PluginStatus.UNINSTALL
: PluginStatus.INSTALL;
if (plugin.isCore) {
if (plugin.isCore || plugin.isDisabled) {
return null;
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import { PluginErrorCode } from '@grafana/data';
import { Alert } from '@grafana/ui';
import { CatalogPlugin } from '../types';
import { selectors } from '@grafana/e2e-selectors';
type Props = {
className?: string;
plugin: CatalogPlugin;
};
export function PluginDetailsDisabledError({ className, plugin }: Props): React.ReactElement | null {
if (!plugin.isDisabled) {
return null;
}
return (
<Alert
severity="error"
title="Plugin disabled"
className={className}
aria-label={selectors.pages.PluginPage.disabledInfo}
>
{renderDescriptionFromError(plugin.error)}
<p>Please contact your server administrator to get this resolved.</p>
<a
href="https://grafana.com/docs/grafana/latest/administration/cli/#plugins-commands"
className="external-link"
target="_blank"
rel="noreferrer"
>
Read more about managing plugins
</a>
</Alert>
);
}
function renderDescriptionFromError(error?: PluginErrorCode): React.ReactElement {
switch (error) {
case PluginErrorCode.modifiedSignature:
return (
<p>
Grafana Labs checks each plugin to verify that it has a valid digital signature. While doing this, we
discovered that the content of this plugin does not match its signature. We can not guarantee the trustworthy
of this plugin and have therefore disabled it. We recommend you to reinstall the plugin to make sure you are
running a verified version of this plugin.
</p>
);
case PluginErrorCode.invalidSignature:
return (
<p>
Grafana Labs checks each plugin to verify that it has a valid digital signature. While doing this, we
discovered that it was invalid. We can not guarantee the trustworthy of this plugin and have therefore
disabled it. We recommend you to reinstall the plugin to make sure you are running a verified version of this
plugin.
</p>
);
case PluginErrorCode.missingSignature:
return (
<p>
Grafana Labs checks each plugin to verify that it has a valid digital signature. While doing this, we
discovered that there is no signature for this plugin. We can not guarantee the trustworthy of this plugin and
have therefore disabled it. We recommend you to reinstall the plugin to make sure you are running a verified
version of this plugin.
</p>
);
default:
return (
<p>
We failed to run this plugin due to an unkown reason and have therefor disabled it. We recommend you to
reinstall the plugin to make sure you are running a working version of this plugin.
</p>
);
}
}

View File

@ -8,6 +8,7 @@ import { PluginDetailsHeaderSignature } from './PluginDetailsHeaderSignature';
import { PluginDetailsHeaderDependencies } from './PluginDetailsHeaderDependencies';
import { PluginLogo } from './PluginLogo';
import { CatalogPlugin } from '../types';
import { PluginDisabledBadge } from './Badges';
type Props = {
currentUrl: string;
@ -72,6 +73,8 @@ export function PluginDetailsHeader({ plugin, currentUrl, parentUrl }: Props): R
{/* Signature information */}
<PluginDetailsHeaderSignature plugin={plugin} />
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error!} />}
</div>
<PluginDetailsHeaderDependencies

View File

@ -1,6 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PluginSignatureStatus } from '@grafana/data';
import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data';
import { PluginListBadges } from './PluginListBadges';
import { CatalogPlugin } from '../types';
import { config } from '@grafana/runtime';
@ -28,6 +28,7 @@ describe('PluginBadges', () => {
isCore: false,
isDev: false,
isEnterprise: false,
isDisabled: false,
};
afterEach(() => {
@ -61,4 +62,9 @@ describe('PluginBadges', () => {
expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /learn more/i })).toBeInTheDocument();
});
it('renders a error badge (when plugin has an error', () => {
render(<PluginListBadges plugin={{ ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature }} />);
expect(screen.getByText(/disabled/i)).toBeVisible();
});
});

View File

@ -1,9 +1,7 @@
import React from 'react';
import { css } from '@emotion/css';
import { Badge, Button, HorizontalGroup, PluginSignatureBadge, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { HorizontalGroup, PluginSignatureBadge } from '@grafana/ui';
import { CatalogPlugin } from '../types';
import { PluginEnterpriseBadge, PluginDisabledBadge, PluginInstalledBadge } from './Badges';
type PluginBadgeType = {
plugin: CatalogPlugin;
@ -11,49 +9,19 @@ type PluginBadgeType = {
export function PluginListBadges({ plugin }: PluginBadgeType) {
if (plugin.isEnterprise) {
return <EnterpriseBadge plugin={plugin} />;
}
return (
<HorizontalGroup>
<PluginSignatureBadge status={plugin.signature} />
{plugin.isInstalled && <InstalledBadge />}
</HorizontalGroup>
);
}
function EnterpriseBadge({ plugin }: { plugin: CatalogPlugin }) {
const customBadgeStyles = useStyles2(getBadgeColor);
const onClick = (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
ev.preventDefault();
window.open(
`https://grafana.com/grafana/plugins/${plugin.id}?utm_source=grafana_catalog_learn_more`,
'_blank',
'noopener,noreferrer'
return (
<HorizontalGroup>
<PluginEnterpriseBadge plugin={plugin} />
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error} />}
</HorizontalGroup>
);
};
if (config.licenseInfo?.hasValidLicense) {
return <Badge text="Enterprise" color="blue" />;
}
return (
<HorizontalGroup>
<PluginSignatureBadge status={plugin.signature} />
<Badge icon="lock" aria-label="lock icon" text="Enterprise" color="blue" className={customBadgeStyles} />
<Button size="sm" fill="text" icon="external-link-alt" onClick={onClick}>
Learn more
</Button>
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error} />}
{plugin.isInstalled && <PluginInstalledBadge />}
</HorizontalGroup>
);
}
function InstalledBadge() {
const customBadgeStyles = useStyles2(getBadgeColor);
return <Badge text="Installed" color="orange" className={customBadgeStyles} />;
}
const getBadgeColor = (theme: GrafanaTheme2) => css`
background: ${theme.colors.background.primary};
border-color: ${theme.colors.border.strong};
color: ${theme.colors.text.secondary};
`;

View File

@ -1,6 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PluginSignatureStatus, PluginType } from '@grafana/data';
import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data';
import { PluginListCard } from './PluginListCard';
import { CatalogPlugin } from '../types';
@ -27,6 +27,7 @@ describe('PluginCard', () => {
isCore: false,
isDev: false,
isEnterprise: false,
isDisabled: false,
};
it('renders a card with link, image, name, orgName and badges', () => {
@ -64,4 +65,11 @@ describe('PluginCard', () => {
expect(screen.getByLabelText(/app plugin icon/i)).toBeVisible();
});
it('renders a disabled plugin with a badge to indicate its error', () => {
const pluginWithError = { ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature };
render(<PluginListCard plugin={pluginWithError} pathName="" />);
expect(screen.getByText(/disabled/i)).toBeVisible();
});
});

View File

@ -1,6 +1,6 @@
import { config } from '@grafana/runtime';
import { gt } from 'semver';
import { PluginSignatureStatus, dateTimeParse } from '@grafana/data';
import { PluginSignatureStatus, dateTimeParse, PluginError } from '@grafana/data';
import { CatalogPlugin, LocalPlugin, RemotePlugin } from './types';
import { contextSrv } from 'app/core/services/context_srv';
@ -12,41 +12,48 @@ export function isOrgAdmin() {
return contextSrv.hasRole('Admin');
}
export function mergeLocalsAndRemotes(local: LocalPlugin[] = [], remote: RemotePlugin[] = []): CatalogPlugin[] {
export function mergeLocalsAndRemotes(
local: LocalPlugin[] = [],
remote: RemotePlugin[] = [],
errors: PluginError[]
): CatalogPlugin[] {
const catalogPlugins: CatalogPlugin[] = [];
const errorByPluginId = groupErrorsByPluginId(errors);
// add locals
local.forEach((l) => {
const remotePlugin = remote.find((r) => r.slug === l.id);
const error = errorByPluginId[l.id];
if (!remotePlugin) {
catalogPlugins.push(mergeLocalAndRemote(l));
catalogPlugins.push(mergeLocalAndRemote(l, undefined, error));
}
});
// add remote
remote.forEach((r) => {
const localPlugin = local.find((l) => l.id === r.slug);
const error = errorByPluginId[r.slug];
catalogPlugins.push(mergeLocalAndRemote(localPlugin, r));
catalogPlugins.push(mergeLocalAndRemote(localPlugin, r, error));
});
return catalogPlugins;
}
export function mergeLocalAndRemote(local?: LocalPlugin, remote?: RemotePlugin): CatalogPlugin {
export function mergeLocalAndRemote(local?: LocalPlugin, remote?: RemotePlugin, error?: PluginError): CatalogPlugin {
if (!local && remote) {
return mapRemoteToCatalog(remote);
return mapRemoteToCatalog(remote, error);
}
if (local && !remote) {
return mapLocalToCatalog(local);
return mapLocalToCatalog(local, error);
}
return mapToCatalogPlugin(local, remote);
return mapToCatalogPlugin(local, remote, error);
}
export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin {
export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): CatalogPlugin {
const {
name,
slug: id,
@ -64,6 +71,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin {
} = plugin;
const hasSignature = signatureType !== '' || versionSignatureType !== '';
const isDisabled = !!error;
const catalogPlugin = {
description,
downloads,
@ -82,16 +90,18 @@ export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin {
updatedAt,
version,
hasUpdate: false,
isInstalled: false,
isInstalled: isDisabled,
isDisabled: isDisabled,
isCore: plugin.internal,
isDev: false,
isEnterprise: status === 'enterprise',
type: typeCode,
error: error?.errorCode,
};
return catalogPlugin;
}
export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): CatalogPlugin {
const {
name,
info: { description, version, logos, updated, author },
@ -119,19 +129,23 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
version,
hasUpdate: false,
isInstalled: true,
isDisabled: !!error,
isCore: signature === 'internal',
isDev: Boolean(dev),
isEnterprise: false,
type,
error: error?.errorCode,
};
}
export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin): CatalogPlugin {
export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, error?: PluginError): CatalogPlugin {
const version = remote?.version || local?.info.version || '';
const hasUpdate =
local?.hasUpdate || Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
const id = remote?.slug || local?.id || '';
const hasRemoteSignature = remote?.signatureType !== '' || remote?.versionSignatureType !== '';
const isDisabled = !!error;
let logos = {
small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small',
large: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/large',
@ -157,7 +171,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin):
isCore: Boolean(remote?.internal || local?.signature === PluginSignatureStatus.internal),
isDev: Boolean(local?.dev),
isEnterprise: remote?.status === 'enterprise',
isInstalled: Boolean(local),
isInstalled: Boolean(local) || isDisabled,
isDisabled: isDisabled,
name: remote?.name || local?.name || '',
orgName: remote?.orgName || local?.info.author.name || '',
popularity: remote?.popularity || 0,
@ -168,6 +183,7 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin):
signatureType: local?.signatureType || remote?.versionSignatureType || remote?.signatureType || undefined,
updatedAt: remote?.updatedAt || local?.info.updated || '',
version,
error: error?.errorCode,
};
}
@ -198,3 +214,10 @@ export const sortPlugins = (plugins: CatalogPlugin[], sortBy: Sorters) => {
return plugins;
};
function groupErrorsByPluginId(errors: PluginError[]): Record<string, PluginError | undefined> {
return errors.reduce((byId, error) => {
byId[error.pluginId] = error;
return byId;
}, {} as Record<string, PluginError | undefined>);
}

View File

@ -8,9 +8,9 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => {
return null;
}
if (plugin.isInstalled) {
if (plugin.isInstalled && !plugin.isDisabled) {
return loadPlugin(plugin.id);
}
return null;
}, [plugin?.id, plugin?.isInstalled]);
}, [plugin?.id, plugin?.isInstalled, plugin?.isDisabled]);
};

View File

@ -83,6 +83,37 @@ describe('Browse list of plugins', () => {
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
});
it('should list all plugins (including disabled plugins) when filtering by all', async () => {
const { queryByText } = renderBrowse('/plugins?filterBy=all&filterByType=all', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: false }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: true, isDisabled: true }),
]);
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
expect(queryByText('Plugin 2')).toBeInTheDocument();
expect(queryByText('Plugin 3')).toBeInTheDocument();
expect(queryByText('Plugin 4')).toBeInTheDocument();
});
it('should list installed plugins (including disabled plugins) when filtering by installed', async () => {
const { queryByText } = renderBrowse('/plugins?filterBy=installed', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: false }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: true, isDisabled: true }),
]);
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
expect(queryByText('Plugin 3')).toBeInTheDocument();
expect(queryByText('Plugin 4')).toBeInTheDocument();
// Not showing not installed plugins
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
});
it('should list enterprise plugins when querying for them', async () => {
const { queryByText } = renderBrowse('/plugins?filterBy=all&q=wavefront', [
getCatalogPluginMock({ id: 'wavefront', name: 'Wavefront', isInstalled: true, isEnterprise: true }),

View File

@ -9,6 +9,8 @@ import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps
import { CatalogPlugin } from '../types';
import * as api from '../api';
import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock } from '../__mocks__';
import { PluginErrorCode } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
// Mock the config to enable the plugin catalog
jest.mock('@grafana/runtime', () => {
@ -171,6 +173,13 @@ describe('Plugin details page', () => {
await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
});
it('should not display install / uninstall buttons for disabled plugins', async () => {
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, isDisabled: true });
await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
});
it('should display install link with `config.pluginAdminExternalManageEnabled` set to true', async () => {
config.pluginAdminExternalManageEnabled = true;
@ -196,6 +205,17 @@ describe('Plugin details page', () => {
expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument();
});
it('should display alert with information about why the plugin is disabled', async () => {
const { queryByLabelText } = renderPluginDetails({
id,
isInstalled: true,
isDisabled: true,
error: PluginErrorCode.modifiedSignature,
});
await waitFor(() => expect(queryByLabelText(selectors.pages.PluginPage.disabledInfo)).toBeInTheDocument());
});
it('should display grafana dependencies for a plugin if they are available', async () => {
const { queryByText } = renderPluginDetails({
id,

View File

@ -14,6 +14,7 @@ import { PluginTabLabels, PluginDetailsTab } from '../types';
import { useGetSingle, useFetchStatus } from '../state/hooks';
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
import { AppNotificationSeverity } from 'app/types';
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
type Props = GrafanaRouteComponentProps<{ pluginId?: string }>;
@ -83,7 +84,8 @@ export default function PluginDetails({ match }: Props): JSX.Element | null {
{/* Active tab */}
<TabContent className={styles.tabContent}>
<PluginDetailsSignature plugin={plugin} className={styles.signature} />
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
<PluginDetailsBody tab={tabs[activeTabIndex]} plugin={plugin} />
</TabContent>
</PluginPage>
@ -93,7 +95,7 @@ export default function PluginDetails({ match }: Props): JSX.Element | null {
export const getStyles = (theme: GrafanaTheme2) => {
return {
signature: css`
alert: css`
margin: ${theme.spacing(3)};
margin-bottom: 0;
`,

View File

@ -1,5 +1,11 @@
import { EntityState } from '@reduxjs/toolkit';
import { PluginType, PluginSignatureStatus, PluginSignatureType, PluginDependencies } from '@grafana/data';
import {
PluginType,
PluginSignatureStatus,
PluginSignatureType,
PluginDependencies,
PluginErrorCode,
} from '@grafana/data';
import { StoreState, PluginsState } from 'app/types';
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
@ -30,6 +36,7 @@ export interface CatalogPlugin {
isCore: boolean;
isEnterprise: boolean;
isInstalled: boolean;
isDisabled: boolean;
name: string;
orgName: string;
signature: PluginSignatureStatus;
@ -41,6 +48,7 @@ export interface CatalogPlugin {
updatedAt: string;
version: string;
details?: CatalogPluginDetails;
error?: PluginErrorCode;
}
export interface CatalogPluginDetails {
@ -185,6 +193,7 @@ export enum PluginStatus {
INSTALL = 'INSTALL',
UNINSTALL = 'UNINSTALL',
UPDATE = 'UPDATE',
REINSTALL = 'REINSTALL',
}
export enum PluginTabLabels {