From f3002931f4df620b1b4397f7fe25052ca38f00a8 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Mon, 20 Sep 2021 09:08:00 +0200 Subject: [PATCH] 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 * 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 --- docs/sources/administration/provisioning.md | 2 +- docs/sources/auth/azuread.md | 2 + .../fine-grained-access-control-references.md | 6 +- .../src/selectors/pages.ts | 1 + pkg/infra/process/root_check.go | 1 + pkg/infra/process/root_check_windows.go | 1 + .../admin/__mocks__/catalogPlugin.mock.ts | 1 + public/app/features/plugins/admin/api.ts | 17 ++++- .../components/Badges/PluginDisabledBadge.tsx | 23 ++++++ .../Badges/PluginEnterpriseBadge.tsx | 33 ++++++++ .../components/Badges/PluginInstallBadge.tsx | 8 ++ .../plugins/admin/components/Badges/index.ts | 3 + .../admin/components/Badges/sharedStyles.ts | 8 ++ .../components/InstallControls/index.tsx | 2 +- .../components/PluginDetailsDisabledError.tsx | 75 +++++++++++++++++++ .../admin/components/PluginDetailsHeader.tsx | 3 + ...ges.test.tsx => PluginListBadges.test.tsx} | 8 +- .../admin/components/PluginListBadges.tsx | 50 +++---------- .../admin/components/PluginListCard.test.tsx | 10 ++- public/app/features/plugins/admin/helpers.ts | 49 ++++++++---- .../plugins/admin/hooks/usePluginConfig.tsx | 4 +- .../plugins/admin/pages/Browse.test.tsx | 31 ++++++++ .../admin/pages/PluginDetails.test.tsx | 20 +++++ .../plugins/admin/pages/PluginDetails.tsx | 6 +- public/app/features/plugins/admin/types.ts | 11 ++- 25 files changed, 307 insertions(+), 68 deletions(-) create mode 100644 public/app/features/plugins/admin/components/Badges/PluginDisabledBadge.tsx create mode 100644 public/app/features/plugins/admin/components/Badges/PluginEnterpriseBadge.tsx create mode 100644 public/app/features/plugins/admin/components/Badges/PluginInstallBadge.tsx create mode 100644 public/app/features/plugins/admin/components/Badges/index.ts create mode 100644 public/app/features/plugins/admin/components/Badges/sharedStyles.ts create mode 100644 public/app/features/plugins/admin/components/PluginDetailsDisabledError.tsx rename public/app/features/plugins/admin/components/{PluginBadges.test.tsx => PluginListBadges.test.tsx} (86%) diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 28404d6d7dd..2d8b19f3c98 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -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' | diff --git a/docs/sources/auth/azuread.md b/docs/sources/auth/azuread.md index b6c86bb1825..ed1e7aefbda 100644 --- a/docs/sources/auth/azuread.md +++ b/docs/sources/auth/azuread.md @@ -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 diff --git a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md index b828e9cef79..0a1b7d56b0e 100644 --- a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md +++ b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md @@ -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`
`fixed:permissions:admin:read`
`fixed:provisioning:admin`
`fixed:reporting:admin:edit`
`fixed:reporting:admin:read`
`fixed:users:admin:edit`
`fixed:users:admin:read`
`fixed:users:org:edit`
`fixed:users:org:read`
`fixed:ldap:admin:edit`
`fixed:ldap:admin:read`
`fixed:server:admin:read`
`fixed:settings:admin:read`
`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`
`fixed:users:org:read`
`fixed:reporting:admin:edit`
`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`
`fixed:users:org:read`
`fixed:reporting:admin:edit`
`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` | diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index c8a927c1921..202a579e73a 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -159,6 +159,7 @@ export const Pages = { PluginPage: { page: 'Plugin page', signatureInfo: 'Plugin signature info', + disabledInfo: 'Plugin disabled info', }, PlaylistForm: { name: 'Playlist name', diff --git a/pkg/infra/process/root_check.go b/pkg/infra/process/root_check.go index bcf58a346eb..20b2556736e 100644 --- a/pkg/infra/process/root_check.go +++ b/pkg/infra/process/root_check.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package process diff --git a/pkg/infra/process/root_check_windows.go b/pkg/infra/process/root_check_windows.go index 41a6c1e5aab..3b1eb3b0a9b 100644 --- a/pkg/infra/process/root_check_windows.go +++ b/pkg/infra/process/root_check_windows.go @@ -1,3 +1,4 @@ +//go:build windows // +build windows package process diff --git a/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts b/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts index af1678a85a0..5f16a962580 100644 --- a/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts +++ b/public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts @@ -16,6 +16,7 @@ export default { isDev: false, isEnterprise: false, isInstalled: false, + isDisabled: false, name: 'Zabbix', orgName: 'Alexander Zobnin', popularity: 0.2093, diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 5eec02b8c92..59f96e07c67 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -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 { - 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 { @@ -68,6 +73,14 @@ async function getPlugin(slug: string): Promise { }; } +async function getPluginErrors(): Promise { + try { + return await getBackendSrv().get(`${API_ROOT}/errors`); + } catch (error) { + return []; + } +} + async function getRemotePlugin(id: string, isInstalled: boolean): Promise { try { return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`); diff --git a/public/app/features/plugins/admin/components/Badges/PluginDisabledBadge.tsx b/public/app/features/plugins/admin/components/Badges/PluginDisabledBadge.tsx new file mode 100644 index 00000000000..22178e64fe4 --- /dev/null +++ b/public/app/features/plugins/admin/components/Badges/PluginDisabledBadge.tsx @@ -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 ; +} + +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}`; + } +} diff --git a/public/app/features/plugins/admin/components/Badges/PluginEnterpriseBadge.tsx b/public/app/features/plugins/admin/components/Badges/PluginEnterpriseBadge.tsx new file mode 100644 index 00000000000..2843e4a75c4 --- /dev/null +++ b/public/app/features/plugins/admin/components/Badges/PluginEnterpriseBadge.tsx @@ -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) => { + 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 ; + } + + return ( + + + + + + ); +} diff --git a/public/app/features/plugins/admin/components/Badges/PluginInstallBadge.tsx b/public/app/features/plugins/admin/components/Badges/PluginInstallBadge.tsx new file mode 100644 index 00000000000..dc035ec77ff --- /dev/null +++ b/public/app/features/plugins/admin/components/Badges/PluginInstallBadge.tsx @@ -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 ; +} diff --git a/public/app/features/plugins/admin/components/Badges/index.ts b/public/app/features/plugins/admin/components/Badges/index.ts new file mode 100644 index 00000000000..12190d31a30 --- /dev/null +++ b/public/app/features/plugins/admin/components/Badges/index.ts @@ -0,0 +1,3 @@ +export { PluginDisabledBadge } from './PluginDisabledBadge'; +export { PluginInstalledBadge } from './PluginInstallBadge'; +export { PluginEnterpriseBadge } from './PluginEnterpriseBadge'; diff --git a/public/app/features/plugins/admin/components/Badges/sharedStyles.ts b/public/app/features/plugins/admin/components/Badges/sharedStyles.ts new file mode 100644 index 00000000000..26486c6ad57 --- /dev/null +++ b/public/app/features/plugins/admin/components/Badges/sharedStyles.ts @@ -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}; +`; diff --git a/public/app/features/plugins/admin/components/InstallControls/index.tsx b/public/app/features/plugins/admin/components/InstallControls/index.tsx index 7aad502ee27..f0d3ae37e97 100644 --- a/public/app/features/plugins/admin/components/InstallControls/index.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/index.tsx @@ -32,7 +32,7 @@ export const InstallControls = ({ plugin }: Props) => { : PluginStatus.UNINSTALL : PluginStatus.INSTALL; - if (plugin.isCore) { + if (plugin.isCore || plugin.isDisabled) { return null; } diff --git a/public/app/features/plugins/admin/components/PluginDetailsDisabledError.tsx b/public/app/features/plugins/admin/components/PluginDetailsDisabledError.tsx new file mode 100644 index 00000000000..88eadc6a23d --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsDisabledError.tsx @@ -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 ( + + {renderDescriptionFromError(plugin.error)} +

Please contact your server administrator to get this resolved.

+ + Read more about managing plugins + +
+ ); +} + +function renderDescriptionFromError(error?: PluginErrorCode): React.ReactElement { + switch (error) { + case PluginErrorCode.modifiedSignature: + return ( +

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

+ ); + case PluginErrorCode.invalidSignature: + return ( +

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

+ ); + case PluginErrorCode.missingSignature: + return ( +

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

+ ); + default: + return ( +

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

+ ); + } +} diff --git a/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx b/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx index caeaeb37b17..072cbdf3ab8 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx @@ -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 */} + + {plugin.isDisabled && } { 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(); + expect(screen.getByText(/disabled/i)).toBeVisible(); + }); }); diff --git a/public/app/features/plugins/admin/components/PluginListBadges.tsx b/public/app/features/plugins/admin/components/PluginListBadges.tsx index be022fd4598..0cca9557459 100644 --- a/public/app/features/plugins/admin/components/PluginListBadges.tsx +++ b/public/app/features/plugins/admin/components/PluginListBadges.tsx @@ -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 ; - } - return ( - - - {plugin.isInstalled && } - - ); -} - -function EnterpriseBadge({ plugin }: { plugin: CatalogPlugin }) { - const customBadgeStyles = useStyles2(getBadgeColor); - const onClick = (ev: React.MouseEvent) => { - ev.preventDefault(); - window.open( - `https://grafana.com/grafana/plugins/${plugin.id}?utm_source=grafana_catalog_learn_more`, - '_blank', - 'noopener,noreferrer' + return ( + + + {plugin.isDisabled && } + ); - }; - - if (config.licenseInfo?.hasValidLicense) { - return ; } return ( - - + {plugin.isDisabled && } + {plugin.isInstalled && } ); } - -function InstalledBadge() { - const customBadgeStyles = useStyles2(getBadgeColor); - return ; -} - -const getBadgeColor = (theme: GrafanaTheme2) => css` - background: ${theme.colors.background.primary}; - border-color: ${theme.colors.border.strong}; - color: ${theme.colors.text.secondary}; -`; diff --git a/public/app/features/plugins/admin/components/PluginListCard.test.tsx b/public/app/features/plugins/admin/components/PluginListCard.test.tsx index 33de131c098..814c980a91a 100644 --- a/public/app/features/plugins/admin/components/PluginListCard.test.tsx +++ b/public/app/features/plugins/admin/components/PluginListCard.test.tsx @@ -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(); + + expect(screen.getByText(/disabled/i)).toBeVisible(); + }); }); diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index 3862ca06599..01ec80bd126 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -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 { + return errors.reduce((byId, error) => { + byId[error.pluginId] = error; + return byId; + }, {} as Record); +} diff --git a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx index cae8a2e50c3..8f8e5da594c 100644 --- a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx @@ -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]); }; diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index e3ee579f084..871265d4ece 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -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 }), diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index 9922876d6ea..1b58b6f7161 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -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, diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index 5a2cd879432..2829dcb4734 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -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 */} - + + @@ -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; `, diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 9cd9f897669..a8d893941d0 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -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 {