mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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
@ -109,10 +109,12 @@ allowed_groups =
|
|||||||
```
|
```
|
||||||
|
|
||||||
You can also use these environment variables to configure **client_id** and **client_secret**:
|
You can also use these environment variables to configure **client_id** and **client_secret**:
|
||||||
|
|
||||||
```
|
```
|
||||||
GF_AUTH_AZUREAD_CLIENT_ID
|
GF_AUTH_AZUREAD_CLIENT_ID
|
||||||
GF_AUTH_AZUREAD_CLIENT_SECRET
|
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**)
|
**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
|
### Configure allowed groups
|
||||||
|
@ -32,7 +32,7 @@ The reference information that follows complements conceptual information about
|
|||||||
## Default built-in role assignments
|
## 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. |
|
| 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` |
|
| Editor | `fixed:datasource:editor:read` |
|
||||||
|
@ -159,6 +159,7 @@ export const Pages = {
|
|||||||
PluginPage: {
|
PluginPage: {
|
||||||
page: 'Plugin page',
|
page: 'Plugin page',
|
||||||
signatureInfo: 'Plugin signature info',
|
signatureInfo: 'Plugin signature info',
|
||||||
|
disabledInfo: 'Plugin disabled info',
|
||||||
},
|
},
|
||||||
PlaylistForm: {
|
PlaylistForm: {
|
||||||
name: 'Playlist name',
|
name: 'Playlist name',
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
//go:build !windows
|
||||||
// +build !windows
|
// +build !windows
|
||||||
|
|
||||||
package process
|
package process
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
//go:build windows
|
||||||
// +build windows
|
// +build windows
|
||||||
|
|
||||||
package process
|
package process
|
||||||
|
@ -16,6 +16,7 @@ export default {
|
|||||||
isDev: false,
|
isDev: false,
|
||||||
isEnterprise: false,
|
isEnterprise: false,
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
|
isDisabled: false,
|
||||||
name: 'Zabbix',
|
name: 'Zabbix',
|
||||||
orgName: 'Alexander Zobnin',
|
orgName: 'Alexander Zobnin',
|
||||||
popularity: 0.2093,
|
popularity: 0.2093,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
|
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
|
||||||
import { mergeLocalsAndRemotes, mergeLocalAndRemote } from './helpers';
|
import { mergeLocalsAndRemotes, mergeLocalAndRemote } from './helpers';
|
||||||
|
import { PluginError } from '@grafana/data';
|
||||||
import {
|
import {
|
||||||
PluginDetails,
|
PluginDetails,
|
||||||
Org,
|
Org,
|
||||||
@ -13,9 +14,13 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export async function getCatalogPlugins(): Promise<CatalogPlugin[]> {
|
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> {
|
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> {
|
async function getRemotePlugin(id: string, isInstalled: boolean): Promise<RemotePlugin | undefined> {
|
||||||
try {
|
try {
|
||||||
return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`);
|
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.UNINSTALL
|
||||||
: PluginStatus.INSTALL;
|
: PluginStatus.INSTALL;
|
||||||
|
|
||||||
if (plugin.isCore) {
|
if (plugin.isCore || plugin.isDisabled) {
|
||||||
return null;
|
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 { PluginDetailsHeaderDependencies } from './PluginDetailsHeaderDependencies';
|
||||||
import { PluginLogo } from './PluginLogo';
|
import { PluginLogo } from './PluginLogo';
|
||||||
import { CatalogPlugin } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
|
import { PluginDisabledBadge } from './Badges';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentUrl: string;
|
currentUrl: string;
|
||||||
@ -72,6 +73,8 @@ export function PluginDetailsHeader({ plugin, currentUrl, parentUrl }: Props): R
|
|||||||
|
|
||||||
{/* Signature information */}
|
{/* Signature information */}
|
||||||
<PluginDetailsHeaderSignature plugin={plugin} />
|
<PluginDetailsHeaderSignature plugin={plugin} />
|
||||||
|
|
||||||
|
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error!} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PluginDetailsHeaderDependencies
|
<PluginDetailsHeaderDependencies
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { PluginSignatureStatus } from '@grafana/data';
|
import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data';
|
||||||
import { PluginListBadges } from './PluginListBadges';
|
import { PluginListBadges } from './PluginListBadges';
|
||||||
import { CatalogPlugin } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
@ -28,6 +28,7 @@ describe('PluginBadges', () => {
|
|||||||
isCore: false,
|
isCore: false,
|
||||||
isDev: false,
|
isDev: false,
|
||||||
isEnterprise: false,
|
isEnterprise: false,
|
||||||
|
isDisabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -61,4 +62,9 @@ describe('PluginBadges', () => {
|
|||||||
expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /learn more/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 React from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { HorizontalGroup, PluginSignatureBadge } from '@grafana/ui';
|
||||||
import { Badge, Button, HorizontalGroup, PluginSignatureBadge, useStyles2 } from '@grafana/ui';
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { CatalogPlugin } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
|
import { PluginEnterpriseBadge, PluginDisabledBadge, PluginInstalledBadge } from './Badges';
|
||||||
|
|
||||||
type PluginBadgeType = {
|
type PluginBadgeType = {
|
||||||
plugin: CatalogPlugin;
|
plugin: CatalogPlugin;
|
||||||
@ -11,49 +9,19 @@ type PluginBadgeType = {
|
|||||||
|
|
||||||
export function PluginListBadges({ plugin }: PluginBadgeType) {
|
export function PluginListBadges({ plugin }: PluginBadgeType) {
|
||||||
if (plugin.isEnterprise) {
|
if (plugin.isEnterprise) {
|
||||||
return <EnterpriseBadge plugin={plugin} />;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<PluginSignatureBadge status={plugin.signature} />
|
<PluginEnterpriseBadge plugin={plugin} />
|
||||||
{plugin.isInstalled && <InstalledBadge />}
|
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error} />}
|
||||||
</HorizontalGroup>
|
</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'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.licenseInfo?.hasValidLicense) {
|
|
||||||
return <Badge text="Enterprise" color="blue" />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<PluginSignatureBadge status={plugin.signature} />
|
<PluginSignatureBadge status={plugin.signature} />
|
||||||
<Badge icon="lock" aria-label="lock icon" text="Enterprise" color="blue" className={customBadgeStyles} />
|
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error} />}
|
||||||
<Button size="sm" fill="text" icon="external-link-alt" onClick={onClick}>
|
{plugin.isInstalled && <PluginInstalledBadge />}
|
||||||
Learn more
|
|
||||||
</Button>
|
|
||||||
</HorizontalGroup>
|
</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 React from 'react';
|
||||||
import { render, screen } from '@testing-library/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 { PluginListCard } from './PluginListCard';
|
||||||
import { CatalogPlugin } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
|
|
||||||
@ -27,6 +27,7 @@ describe('PluginCard', () => {
|
|||||||
isCore: false,
|
isCore: false,
|
||||||
isDev: false,
|
isDev: false,
|
||||||
isEnterprise: false,
|
isEnterprise: false,
|
||||||
|
isDisabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders a card with link, image, name, orgName and badges', () => {
|
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();
|
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 { config } from '@grafana/runtime';
|
||||||
import { gt } from 'semver';
|
import { gt } from 'semver';
|
||||||
import { PluginSignatureStatus, dateTimeParse } from '@grafana/data';
|
import { PluginSignatureStatus, dateTimeParse, PluginError } from '@grafana/data';
|
||||||
import { CatalogPlugin, LocalPlugin, RemotePlugin } from './types';
|
import { CatalogPlugin, LocalPlugin, RemotePlugin } from './types';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
|
|
||||||
@ -12,41 +12,48 @@ export function isOrgAdmin() {
|
|||||||
return contextSrv.hasRole('Admin');
|
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 catalogPlugins: CatalogPlugin[] = [];
|
||||||
|
const errorByPluginId = groupErrorsByPluginId(errors);
|
||||||
|
|
||||||
// add locals
|
// add locals
|
||||||
local.forEach((l) => {
|
local.forEach((l) => {
|
||||||
const remotePlugin = remote.find((r) => r.slug === l.id);
|
const remotePlugin = remote.find((r) => r.slug === l.id);
|
||||||
|
const error = errorByPluginId[l.id];
|
||||||
|
|
||||||
if (!remotePlugin) {
|
if (!remotePlugin) {
|
||||||
catalogPlugins.push(mergeLocalAndRemote(l));
|
catalogPlugins.push(mergeLocalAndRemote(l, undefined, error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// add remote
|
// add remote
|
||||||
remote.forEach((r) => {
|
remote.forEach((r) => {
|
||||||
const localPlugin = local.find((l) => l.id === r.slug);
|
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;
|
return catalogPlugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeLocalAndRemote(local?: LocalPlugin, remote?: RemotePlugin): CatalogPlugin {
|
export function mergeLocalAndRemote(local?: LocalPlugin, remote?: RemotePlugin, error?: PluginError): CatalogPlugin {
|
||||||
if (!local && remote) {
|
if (!local && remote) {
|
||||||
return mapRemoteToCatalog(remote);
|
return mapRemoteToCatalog(remote, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local && !remote) {
|
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 {
|
const {
|
||||||
name,
|
name,
|
||||||
slug: id,
|
slug: id,
|
||||||
@ -64,6 +71,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin {
|
|||||||
} = plugin;
|
} = plugin;
|
||||||
|
|
||||||
const hasSignature = signatureType !== '' || versionSignatureType !== '';
|
const hasSignature = signatureType !== '' || versionSignatureType !== '';
|
||||||
|
const isDisabled = !!error;
|
||||||
const catalogPlugin = {
|
const catalogPlugin = {
|
||||||
description,
|
description,
|
||||||
downloads,
|
downloads,
|
||||||
@ -82,16 +90,18 @@ export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin {
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
version,
|
version,
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
isInstalled: false,
|
isInstalled: isDisabled,
|
||||||
|
isDisabled: isDisabled,
|
||||||
isCore: plugin.internal,
|
isCore: plugin.internal,
|
||||||
isDev: false,
|
isDev: false,
|
||||||
isEnterprise: status === 'enterprise',
|
isEnterprise: status === 'enterprise',
|
||||||
type: typeCode,
|
type: typeCode,
|
||||||
|
error: error?.errorCode,
|
||||||
};
|
};
|
||||||
return catalogPlugin;
|
return catalogPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
|
export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): CatalogPlugin {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
info: { description, version, logos, updated, author },
|
info: { description, version, logos, updated, author },
|
||||||
@ -119,19 +129,23 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
|
|||||||
version,
|
version,
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
isInstalled: true,
|
isInstalled: true,
|
||||||
|
isDisabled: !!error,
|
||||||
isCore: signature === 'internal',
|
isCore: signature === 'internal',
|
||||||
isDev: Boolean(dev),
|
isDev: Boolean(dev),
|
||||||
isEnterprise: false,
|
isEnterprise: false,
|
||||||
type,
|
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 version = remote?.version || local?.info.version || '';
|
||||||
const hasUpdate =
|
const hasUpdate =
|
||||||
local?.hasUpdate || Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
|
local?.hasUpdate || Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
|
||||||
const id = remote?.slug || local?.id || '';
|
const id = remote?.slug || local?.id || '';
|
||||||
const hasRemoteSignature = remote?.signatureType !== '' || remote?.versionSignatureType !== '';
|
const hasRemoteSignature = remote?.signatureType !== '' || remote?.versionSignatureType !== '';
|
||||||
|
const isDisabled = !!error;
|
||||||
|
|
||||||
let logos = {
|
let logos = {
|
||||||
small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small',
|
small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small',
|
||||||
large: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/large',
|
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),
|
isCore: Boolean(remote?.internal || local?.signature === PluginSignatureStatus.internal),
|
||||||
isDev: Boolean(local?.dev),
|
isDev: Boolean(local?.dev),
|
||||||
isEnterprise: remote?.status === 'enterprise',
|
isEnterprise: remote?.status === 'enterprise',
|
||||||
isInstalled: Boolean(local),
|
isInstalled: Boolean(local) || isDisabled,
|
||||||
|
isDisabled: isDisabled,
|
||||||
name: remote?.name || local?.name || '',
|
name: remote?.name || local?.name || '',
|
||||||
orgName: remote?.orgName || local?.info.author.name || '',
|
orgName: remote?.orgName || local?.info.author.name || '',
|
||||||
popularity: remote?.popularity || 0,
|
popularity: remote?.popularity || 0,
|
||||||
@ -168,6 +183,7 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin):
|
|||||||
signatureType: local?.signatureType || remote?.versionSignatureType || remote?.signatureType || undefined,
|
signatureType: local?.signatureType || remote?.versionSignatureType || remote?.signatureType || undefined,
|
||||||
updatedAt: remote?.updatedAt || local?.info.updated || '',
|
updatedAt: remote?.updatedAt || local?.info.updated || '',
|
||||||
version,
|
version,
|
||||||
|
error: error?.errorCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,3 +214,10 @@ export const sortPlugins = (plugins: CatalogPlugin[], sortBy: Sorters) => {
|
|||||||
|
|
||||||
return plugins;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugin.isInstalled) {
|
if (plugin.isInstalled && !plugin.isDisabled) {
|
||||||
return loadPlugin(plugin.id);
|
return loadPlugin(plugin.id);
|
||||||
}
|
}
|
||||||
return null;
|
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();
|
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 () => {
|
it('should list enterprise plugins when querying for them', async () => {
|
||||||
const { queryByText } = renderBrowse('/plugins?filterBy=all&q=wavefront', [
|
const { queryByText } = renderBrowse('/plugins?filterBy=all&q=wavefront', [
|
||||||
getCatalogPluginMock({ id: 'wavefront', name: 'Wavefront', isInstalled: true, isEnterprise: true }),
|
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 { CatalogPlugin } from '../types';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock } from '../__mocks__';
|
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
|
// Mock the config to enable the plugin catalog
|
||||||
jest.mock('@grafana/runtime', () => {
|
jest.mock('@grafana/runtime', () => {
|
||||||
@ -171,6 +173,13 @@ describe('Plugin details page', () => {
|
|||||||
await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
|
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 () => {
|
it('should display install link with `config.pluginAdminExternalManageEnabled` set to true', async () => {
|
||||||
config.pluginAdminExternalManageEnabled = true;
|
config.pluginAdminExternalManageEnabled = true;
|
||||||
|
|
||||||
@ -196,6 +205,17 @@ describe('Plugin details page', () => {
|
|||||||
expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument();
|
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 () => {
|
it('should display grafana dependencies for a plugin if they are available', async () => {
|
||||||
const { queryByText } = renderPluginDetails({
|
const { queryByText } = renderPluginDetails({
|
||||||
id,
|
id,
|
||||||
|
@ -14,6 +14,7 @@ import { PluginTabLabels, PluginDetailsTab } from '../types';
|
|||||||
import { useGetSingle, useFetchStatus } from '../state/hooks';
|
import { useGetSingle, useFetchStatus } from '../state/hooks';
|
||||||
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
|
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
|
||||||
import { AppNotificationSeverity } from 'app/types';
|
import { AppNotificationSeverity } from 'app/types';
|
||||||
|
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
|
||||||
|
|
||||||
type Props = GrafanaRouteComponentProps<{ pluginId?: string }>;
|
type Props = GrafanaRouteComponentProps<{ pluginId?: string }>;
|
||||||
|
|
||||||
@ -83,7 +84,8 @@ export default function PluginDetails({ match }: Props): JSX.Element | null {
|
|||||||
|
|
||||||
{/* Active tab */}
|
{/* Active tab */}
|
||||||
<TabContent className={styles.tabContent}>
|
<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} />
|
<PluginDetailsBody tab={tabs[activeTabIndex]} plugin={plugin} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</PluginPage>
|
</PluginPage>
|
||||||
@ -93,7 +95,7 @@ export default function PluginDetails({ match }: Props): JSX.Element | null {
|
|||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2) => {
|
export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
signature: css`
|
alert: css`
|
||||||
margin: ${theme.spacing(3)};
|
margin: ${theme.spacing(3)};
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
`,
|
`,
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { EntityState } from '@reduxjs/toolkit';
|
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';
|
import { StoreState, PluginsState } from 'app/types';
|
||||||
|
|
||||||
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
|
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
|
||||||
@ -30,6 +36,7 @@ export interface CatalogPlugin {
|
|||||||
isCore: boolean;
|
isCore: boolean;
|
||||||
isEnterprise: boolean;
|
isEnterprise: boolean;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
orgName: string;
|
orgName: string;
|
||||||
signature: PluginSignatureStatus;
|
signature: PluginSignatureStatus;
|
||||||
@ -41,6 +48,7 @@ export interface CatalogPlugin {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: string;
|
version: string;
|
||||||
details?: CatalogPluginDetails;
|
details?: CatalogPluginDetails;
|
||||||
|
error?: PluginErrorCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogPluginDetails {
|
export interface CatalogPluginDetails {
|
||||||
@ -185,6 +193,7 @@ export enum PluginStatus {
|
|||||||
INSTALL = 'INSTALL',
|
INSTALL = 'INSTALL',
|
||||||
UNINSTALL = 'UNINSTALL',
|
UNINSTALL = 'UNINSTALL',
|
||||||
UPDATE = 'UPDATE',
|
UPDATE = 'UPDATE',
|
||||||
|
REINSTALL = 'REINSTALL',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PluginTabLabels {
|
export enum PluginTabLabels {
|
||||||
|
Loading…
Reference in New Issue
Block a user