Plugins: Show deprecated plugins (#74598)

* feat: add a `isDeprecated` field to `CatalogPlugin`

* tests: update the tests for merging local & remote

* feat: display a deprecated badge in the plugins list

* feat: show a deprecated warning if the plugin is deprecated

* Fix linting issues

* Review notes

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* refactor: remove `isDeprecated` from the details (it's already in the main CatalogPlugin object)

* refactor: use an enum for remote statuses

---------

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Levente Balogh 2023-09-12 12:49:10 +02:00 committed by GitHub
parent e4f26a5e4b
commit 2fac3bd41e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 185 additions and 34 deletions

View File

@ -17,6 +17,7 @@ export default {
isEnterprise: false,
isInstalled: false,
isDisabled: false,
isDeprecated: false,
isPublished: true,
name: 'Zabbix',
orgName: 'Alexander Zobnin',

View File

@ -3,7 +3,7 @@ import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { API_ROOT, GCOM_API_ROOT } from './constants';
import { isLocalPluginVisible, isRemotePluginVisible } from './helpers';
import { isLocalPluginVisibleByConfig, isRemotePluginVisibleByConfig } from './helpers';
import { LocalPlugin, RemotePlugin, CatalogPluginDetails, Version, PluginVersion } from './types';
export async function getPluginDetails(id: string): Promise<CatalogPluginDetails> {
@ -29,9 +29,13 @@ export async function getPluginDetails(id: string): Promise<CatalogPluginDetails
}
export async function getRemotePlugins(): Promise<RemotePlugin[]> {
const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`);
// We are also fetching deprecated plugins, because we would like to be able to label plugins in the list that are both installed and deprecated.
// (We won't show not installed deprecated plugins in the list)
const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, {
includeDeprecated: true,
});
return remotePlugins.filter(isRemotePluginVisible);
return remotePlugins.filter(isRemotePluginVisibleByConfig);
}
export async function getPluginErrors(): Promise<PluginError[]> {
@ -97,7 +101,7 @@ export async function getLocalPlugins(): Promise<LocalPlugin[]> {
accessControlQueryParam({ embedded: 0 })
);
return localPlugins.filter(isLocalPluginVisible);
return localPlugins.filter(isLocalPluginVisibleByConfig);
}
export async function installPlugin(id: string) {

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Badge } from '@grafana/ui';
export function PluginDeprecatedBadge(): React.ReactElement {
return (
<Badge
icon="exclamation-triangle"
text="Deprecated"
color="orange"
tooltip="This plugin is deprecated and no longer receives updates."
/>
);
}

View File

@ -3,3 +3,4 @@ export { PluginInstalledBadge } from './PluginInstallBadge';
export { PluginEnterpriseBadge } from './PluginEnterpriseBadge';
export { PluginUpdateAvailableBadge } from './PluginUpdateAvailableBadge';
export { PluginAngularBadge } from './PluginAngularBadge';
export { PluginDeprecatedBadge } from './PluginDeprecatedBadge';

View File

@ -28,6 +28,7 @@ const plugin: CatalogPlugin = {
isDev: false,
isEnterprise: false,
isDisabled: false,
isDeprecated: false,
isPublished: true,
};

View File

@ -0,0 +1,25 @@
import React, { useState } from 'react';
import { Alert } from '@grafana/ui';
import { CatalogPlugin } from '../types';
type Props = {
className?: string;
plugin: CatalogPlugin;
};
export function PluginDetailsDeprecatedWarning(props: Props): React.ReactElement | null {
const { className, plugin } = props;
const [dismissed, setDismissed] = useState(false);
const isWarningVisible = plugin.isDeprecated && !dismissed;
return isWarningVisible ? (
<Alert severity="warning" title="Deprecated" className={className} onRemove={() => setDismissed(true)}>
<p>
This {plugin.type} plugin is deprecated and removed from the catalog. No further updates will be made to the
plugin.
</p>
</Alert>
) : null;
}

View File

@ -19,6 +19,8 @@ import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
import { PluginTabIds } from '../types';
import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning';
export type Props = {
// The ID of the plugin
pluginId: string;
@ -87,6 +89,7 @@ export function PluginDetailsPage({
)}
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
<PluginDetailsDeprecatedWarning plugin={plugin} className={styles.alert} />
<PluginDetailsBody queryParams={Object.fromEntries(queryParams)} plugin={plugin} pageId={activePageId} />
</TabContent>
</Page.Contents>

View File

@ -45,6 +45,7 @@ const getMockPlugin = (id: string): CatalogPlugin => {
isDev: false,
isEnterprise: false,
isDisabled: false,
isDeprecated: false,
isPublished: true,
};
};

View File

@ -55,6 +55,7 @@ describe('PluginListItem', () => {
isDev: false,
isEnterprise: false,
isDisabled: false,
isDeprecated: false,
isPublished: true,
};

View File

@ -31,6 +31,7 @@ describe('PluginListItemBadges', () => {
isDev: false,
isEnterprise: false,
isDisabled: false,
isDeprecated: false,
isPublished: true,
};

View File

@ -11,6 +11,7 @@ import {
PluginInstalledBadge,
PluginUpdateAvailableBadge,
PluginAngularBadge,
PluginDeprecatedBadge,
} from './Badges';
type PluginBadgeType = {
@ -35,6 +36,7 @@ export function PluginListItemBadges({ plugin }: PluginBadgeType) {
<HorizontalGroup height="auto" wrap>
<PluginSignatureBadge status={plugin.signature} />
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error} />}
{plugin.isDeprecated && <PluginDeprecatedBadge />}
{plugin.isInstalled && <PluginInstalledBadge />}
{hasUpdate && <PluginUpdateAvailableBadge plugin={plugin} />}
{plugin.angularDetected && <PluginAngularBadge />}

View File

@ -10,10 +10,10 @@ import {
mergeLocalsAndRemotes,
sortPlugins,
Sorters,
isLocalPluginVisible,
isRemotePluginVisible,
isLocalPluginVisibleByConfig,
isRemotePluginVisibleByConfig,
} from './helpers';
import { RemotePlugin, LocalPlugin } from './types';
import { RemotePlugin, LocalPlugin, RemotePluginStatus } from './types';
describe('Plugins/Helpers', () => {
let remotePlugin: RemotePlugin;
@ -65,6 +65,29 @@ describe('Plugins/Helpers', () => {
// Only remote
expect(findMerged('plugin-4')).toEqual(mergeLocalAndRemote(undefined, getRemotePluginMock({ slug: 'plugin-4' })));
});
test('skips deprecated plugins unless they have a local - installed - counterpart', () => {
const merged = mergeLocalsAndRemotes(localPlugins, [
...remotePlugins,
getRemotePluginMock({ slug: 'plugin-5', status: RemotePluginStatus.Deprecated }),
]);
const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId);
expect(merged).toHaveLength(4);
expect(findMerged('plugin-5')).toBeUndefined();
});
test('keeps deprecated plugins in case they have a local counterpart', () => {
const merged = mergeLocalsAndRemotes(
[...localPlugins, getLocalPluginMock({ id: 'plugin-5' })],
[...remotePlugins, getRemotePluginMock({ slug: 'plugin-5', status: RemotePluginStatus.Deprecated })]
);
const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId);
expect(merged).toHaveLength(5);
expect(findMerged('plugin-5')).not.toBeUndefined();
expect(findMerged('plugin-5')?.isDeprecated).toBe(true);
});
});
describe('mergeLocalAndRemote()', () => {
@ -100,6 +123,7 @@ describe('Plugins/Helpers', () => {
isDisabled: false,
isEnterprise: false,
isInstalled: false,
isDeprecated: false,
isPublished: true,
name: 'Zabbix',
orgName: 'Alexander Zobnin',
@ -139,8 +163,8 @@ describe('Plugins/Helpers', () => {
});
test('adds an "isEnterprise" field', () => {
const enterprisePlugin = { ...remotePlugin, status: 'enterprise' } as RemotePlugin;
const notEnterprisePlugin = { ...remotePlugin, status: 'unknown' } as RemotePlugin;
const enterprisePlugin = { ...remotePlugin, status: RemotePluginStatus.Enterprise } as RemotePlugin;
const notEnterprisePlugin = { ...remotePlugin, status: RemotePluginStatus.Active } as RemotePlugin;
expect(mapRemoteToCatalog(enterprisePlugin).isEnterprise).toBe(true);
expect(mapRemoteToCatalog(notEnterprisePlugin).isEnterprise).toBe(false);
@ -175,6 +199,7 @@ describe('Plugins/Helpers', () => {
isEnterprise: false,
isInstalled: true,
isPublished: false,
isDeprecated: false,
name: 'Zabbix',
orgName: 'Alexander Zobnin',
popularity: 0,
@ -223,6 +248,7 @@ describe('Plugins/Helpers', () => {
isEnterprise: false,
isInstalled: true,
isPublished: true,
isDeprecated: false,
name: 'Zabbix',
orgName: 'Alexander Zobnin',
popularity: 0.2111,
@ -319,15 +345,17 @@ describe('Plugins/Helpers', () => {
test('`.isEnterprise` - prefers the remote', () => {
// Local & Remote
expect(mapToCatalogPlugin(localPlugin, { ...remotePlugin, status: 'enterprise' })).toMatchObject({
isEnterprise: true,
});
expect(mapToCatalogPlugin(localPlugin, { ...remotePlugin, status: 'unknown' })).toMatchObject({
expect(mapToCatalogPlugin(localPlugin, { ...remotePlugin, status: RemotePluginStatus.Enterprise })).toMatchObject(
{
isEnterprise: true,
}
);
expect(mapToCatalogPlugin(localPlugin, { ...remotePlugin, status: RemotePluginStatus.Active })).toMatchObject({
isEnterprise: false,
});
// Remote only
expect(mapToCatalogPlugin(undefined, { ...remotePlugin, status: 'enterprise' })).toMatchObject({
expect(mapToCatalogPlugin(undefined, { ...remotePlugin, status: RemotePluginStatus.Enterprise })).toMatchObject({
isEnterprise: true,
});
@ -338,6 +366,34 @@ describe('Plugins/Helpers', () => {
expect(mapToCatalogPlugin()).toMatchObject({ isEnterprise: false });
});
test('`.isDeprecated` - comes from the remote', () => {
// Local & Remote
expect(mapToCatalogPlugin(localPlugin, { ...remotePlugin, status: RemotePluginStatus.Deprecated })).toMatchObject(
{
isDeprecated: true,
}
);
expect(mapToCatalogPlugin(localPlugin, { ...remotePlugin, status: RemotePluginStatus.Enterprise })).toMatchObject(
{
isDeprecated: false,
}
);
// Remote only
expect(mapToCatalogPlugin(undefined, { ...remotePlugin, status: RemotePluginStatus.Deprecated })).toMatchObject({
isDeprecated: true,
});
expect(mapToCatalogPlugin(undefined, { ...remotePlugin, status: RemotePluginStatus.Enterprise })).toMatchObject({
isDeprecated: false,
});
// Local only
expect(mapToCatalogPlugin(localPlugin)).toMatchObject({ isDeprecated: false });
// No local or remote
expect(mapToCatalogPlugin()).toMatchObject({ isDeprecated: false });
});
test('`.isInstalled` - prefers the local', () => {
// Local & Remote
expect(mapToCatalogPlugin(localPlugin, remotePlugin)).toMatchObject({ isInstalled: true });
@ -671,7 +727,7 @@ describe('Plugins/Helpers', () => {
id: 'barchart',
});
expect(isLocalPluginVisible(plugin)).toBe(true);
expect(isLocalPluginVisibleByConfig(plugin)).toBe(true);
});
test('should return FALSE if the plugin is listed as hidden in the main Grafana configuration', () => {
@ -680,7 +736,7 @@ describe('Plugins/Helpers', () => {
id: 'akumuli-datasource',
});
expect(isLocalPluginVisible(plugin)).toBe(false);
expect(isLocalPluginVisibleByConfig(plugin)).toBe(false);
});
});
@ -691,7 +747,7 @@ describe('Plugins/Helpers', () => {
slug: 'barchart',
});
expect(isRemotePluginVisible(plugin)).toBe(true);
expect(isRemotePluginVisibleByConfig(plugin)).toBe(true);
});
test('should return FALSE if the plugin is listed as hidden in the main Grafana configuration', () => {
@ -700,7 +756,7 @@ describe('Plugins/Helpers', () => {
slug: 'akumuli-datasource',
});
expect(isRemotePluginVisible(plugin)).toBe(false);
expect(isRemotePluginVisibleByConfig(plugin)).toBe(false);
});
});
});

View File

@ -5,7 +5,7 @@ import { contextSrv } from 'app/core/core';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { AccessControlAction } from 'app/types';
import { CatalogPlugin, LocalPlugin, RemotePlugin, Version } from './types';
import { CatalogPlugin, LocalPlugin, RemotePlugin, RemotePluginStatus, Version } from './types';
export function mergeLocalsAndRemotes(
local: LocalPlugin[] = [],
@ -16,21 +16,24 @@ export function mergeLocalsAndRemotes(
const errorByPluginId = groupErrorsByPluginId(errors);
// add locals
local.forEach((l) => {
const remotePlugin = remote.find((r) => r.slug === l.id);
const error = errorByPluginId[l.id];
local.forEach((localPlugin) => {
const remoteCounterpart = remote.find((r) => r.slug === localPlugin.id);
const error = errorByPluginId[localPlugin.id];
if (!remotePlugin) {
catalogPlugins.push(mergeLocalAndRemote(l, undefined, error));
if (!remoteCounterpart) {
catalogPlugins.push(mergeLocalAndRemote(localPlugin, undefined, error));
}
});
// add remote
remote.forEach((r) => {
const localPlugin = local.find((l) => l.id === r.slug);
const error = errorByPluginId[r.slug];
remote.forEach((remotePlugin) => {
const localCounterpart = local.find((l) => l.id === remotePlugin.slug);
const error = errorByPluginId[remotePlugin.slug];
const shouldSkip = remotePlugin.status === RemotePluginStatus.Deprecated && !localCounterpart; // We are only listing deprecated plugins in case they are installed.
catalogPlugins.push(mergeLocalAndRemote(localPlugin, r, error));
if (!shouldSkip) {
catalogPlugins.push(mergeLocalAndRemote(localCounterpart, remotePlugin, error));
}
});
return catalogPlugins;
@ -85,9 +88,10 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
isPublished: true,
isInstalled: isDisabled,
isDisabled: isDisabled,
isDeprecated: status === RemotePluginStatus.Deprecated,
isCore: plugin.internal,
isDev: false,
isEnterprise: status === 'enterprise',
isEnterprise: status === RemotePluginStatus.Enterprise,
type: typeCode,
error: error?.errorCode,
angularDetected,
@ -129,6 +133,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
isDisabled: isDisabled,
isCore: signature === 'internal',
isPublished: false,
isDeprecated: false,
isDev: Boolean(dev),
isEnterprise: false,
type,
@ -169,9 +174,10 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
},
isCore: Boolean(remote?.internal || local?.signature === PluginSignatureStatus.internal),
isDev: Boolean(local?.dev),
isEnterprise: remote?.status === 'enterprise',
isEnterprise: remote?.status === RemotePluginStatus.Enterprise,
isInstalled: Boolean(local) || isDisabled,
isDisabled: isDisabled,
isDeprecated: remote?.status === RemotePluginStatus.Deprecated,
isPublished: true,
// TODO<check if we would like to keep preferring the remote version>
name: remote?.name || local?.name || '',
@ -296,11 +302,11 @@ export const hasInstallControlWarning = (
);
};
export const isLocalPluginVisible = (p: LocalPlugin) => isPluginVisible(p.id);
export const isLocalPluginVisibleByConfig = (p: LocalPlugin) => isNotHiddenByConfig(p.id);
export const isRemotePluginVisible = (p: RemotePlugin) => isPluginVisible(p.slug);
export const isRemotePluginVisibleByConfig = (p: RemotePlugin) => isNotHiddenByConfig(p.slug);
function isPluginVisible(id: string) {
function isNotHiddenByConfig(id: string) {
const { pluginCatalogHiddenPlugins }: { pluginCatalogHiddenPlugins: string[] } = config;
return !pluginCatalogHiddenPlugins.includes(id);

View File

@ -743,6 +743,30 @@ describe('Plugin details page', () => {
await waitFor(() => expect(queryByText(/angular plugin/i)).not.toBeInTheDocument);
});
it('should display a deprecation warning if the plugin is deprecated', async () => {
const { queryByText } = renderPluginDetails({
id,
isInstalled: true,
isDeprecated: true,
});
await waitFor(() =>
expect(queryByText(/plugin is deprecated and removed from the catalog/i)).toBeInTheDocument()
);
});
it('should not display a deprecation warning in the plugin is not deprecated', async () => {
const { queryByText } = renderPluginDetails({
id,
isInstalled: true,
isDeprecated: false,
});
await waitFor(() =>
expect(queryByText(/plugin is deprecated and removed from the catalog/i)).not.toBeInTheDocument()
);
});
});
describe('viewed as user without grafana admin permissions', () => {

View File

@ -43,6 +43,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
isEnterprise: boolean;
isInstalled: boolean;
isDisabled: boolean;
isDeprecated: boolean;
// `isPublished` is TRUE if the plugin is published to grafana.com
isPublished: boolean;
name: string;
@ -111,7 +112,7 @@ export type RemotePlugin = {
readme?: string;
signatureType: PluginSignatureType | '';
slug: string;
status: string;
status: RemotePluginStatus;
typeCode: PluginType;
typeId: number;
typeName: string;
@ -127,6 +128,16 @@ export type RemotePlugin = {
angularDetected?: boolean;
};
// The available status codes on GCOM are available here:
// https://github.com/grafana/grafana-com/blob/main/packages/grafana-com-plugins-api/src/plugins/plugin.model.js#L74
export enum RemotePluginStatus {
Deleted = 'deleted',
Active = 'active',
Pending = 'pending',
Deprecated = 'deprecated',
Enterprise = 'enterprise',
}
export type LocalPlugin = WithAccessControlMetadata & {
category: string;
defaultNavUrl: string;