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>
pull/39407/head
Marcus Andersson 4 years ago committed by GitHub
parent a899e9be10
commit f3002931f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      docs/sources/administration/provisioning.md
  2. 2
      docs/sources/auth/azuread.md
  3. 6
      docs/sources/enterprise/access-control/fine-grained-access-control-references.md
  4. 1
      packages/grafana-e2e-selectors/src/selectors/pages.ts
  5. 1
      pkg/infra/process/root_check.go
  6. 1
      pkg/infra/process/root_check_windows.go
  7. 1
      public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts
  8. 17
      public/app/features/plugins/admin/api.ts
  9. 23
      public/app/features/plugins/admin/components/Badges/PluginDisabledBadge.tsx
  10. 33
      public/app/features/plugins/admin/components/Badges/PluginEnterpriseBadge.tsx
  11. 8
      public/app/features/plugins/admin/components/Badges/PluginInstallBadge.tsx
  12. 3
      public/app/features/plugins/admin/components/Badges/index.ts
  13. 8
      public/app/features/plugins/admin/components/Badges/sharedStyles.ts
  14. 2
      public/app/features/plugins/admin/components/InstallControls/index.tsx
  15. 75
      public/app/features/plugins/admin/components/PluginDetailsDisabledError.tsx
  16. 3
      public/app/features/plugins/admin/components/PluginDetailsHeader.tsx
  17. 8
      public/app/features/plugins/admin/components/PluginListBadges.test.tsx
  18. 50
      public/app/features/plugins/admin/components/PluginListBadges.tsx
  19. 10
      public/app/features/plugins/admin/components/PluginListCard.test.tsx
  20. 49
      public/app/features/plugins/admin/helpers.ts
  21. 4
      public/app/features/plugins/admin/hooks/usePluginConfig.tsx
  22. 31
      public/app/features/plugins/admin/pages/Browse.test.tsx
  23. 20
      public/app/features/plugins/admin/pages/PluginDetails.test.tsx
  24. 6
      public/app/features/plugins/admin/pages/PluginDetails.tsx
  25. 11
      public/app/features/plugins/admin/types.ts

@ -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' |

@ -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

@ -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` |

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

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

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

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

@ -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}`);

@ -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}`;
}
}

@ -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>
);
}

@ -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} />;
}

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

@ -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};
`;

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

@ -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>
);
}
}

@ -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

@ -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();
});
});

@ -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};
`;

@ -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();
});
});

@ -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>);
}

@ -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]);
};

@ -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 }),

@ -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,

@ -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;
`,

@ -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 {

Loading…
Cancel
Save