mirror of
https://github.com/grafana/grafana.git
synced 2024-12-25 16:31:28 -06:00
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:
parent
a899e9be10
commit
f3002931f4
@ -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…
Reference in New Issue
Block a user