From e9a2828f668f88d417c9fc00d0c2b581c65aadd8 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Fri, 19 Dec 2025 13:40:41 +0100 Subject: [PATCH] Plugins: Add PluginInsights UI (#115616) * Add getInsights endpoint, add new component PluginInsights * fix linting and add styles * add version option to insights request * Add plugininsights tests, remove console.logs * fix the insight items types * Add getting insights to all the mocks to fix the tests * remove deprecated lint package * Add theme colors, added tests to PluginDetailsPanel * Fix eslint error for plugin details page * Add pluginInsights feature toggle * change getInsights with version API call, resolve conflicts with main * fix typecheck and translation * updated UI * update registry go * fix translation * light css changes * remove duplicated feature toggle * fix the build * update plugin insights tests * fix typecheck * rudderstack added, feedback form added * fix translation * Remove isPluginTabId function --- .../src/types/featureToggles.gen.ts | 5 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 14 ++ public/app/features/plugins/admin/api.ts | 16 ++ .../components/PluginDetailsPage.test.tsx | 2 + .../admin/components/PluginDetailsPage.tsx | 4 +- .../components/PluginDetailsPanel.test.tsx | 71 +++++++- .../admin/components/PluginDetailsPanel.tsx | 9 +- .../admin/components/PluginInsights.test.tsx | 171 ++++++++++++++++++ .../admin/components/PluginInsights.tsx | 140 ++++++++++++++ .../plugins/admin/mocks/catalogPlugin.mock.ts | 2 + .../plugins/admin/mocks/mockHelpers.ts | 8 + .../features/plugins/admin/state/actions.ts | 20 +- .../app/features/plugins/admin/state/hooks.ts | 29 ++- .../features/plugins/admin/state/reducer.ts | 5 + public/app/features/plugins/admin/types.ts | 49 +++++ public/locales/en-US/grafana.json | 6 + 18 files changed, 554 insertions(+), 6 deletions(-) create mode 100644 public/app/features/plugins/admin/components/PluginInsights.test.tsx create mode 100644 public/app/features/plugins/admin/components/PluginInsights.tsx diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ea9f1c22f39..19a8fbf2c44 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1193,6 +1193,11 @@ export interface FeatureToggles { */ onlyStoreActionSets?: boolean; /** + * Show insights for plugins in the plugin details page + * @default false + */ + pluginInsights?: boolean; + /** * Enables a new panel time settings drawer */ panelTimeSettings?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c5aaf9fbf63..7e876849dfe 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1968,6 +1968,14 @@ var ( Owner: identityAccessTeam, Expression: "true", }, + { + Name: "pluginInsights", + Description: "Show insights for plugins in the plugin details page", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaPluginsPlatformSquad, + Expression: "false", + }, { Name: "panelTimeSettings", Description: "Enables a new panel time settings drawer", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6889cb9c040..510c05a815b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -267,6 +267,7 @@ jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false newPanelPadding,preview,@grafana/dashboards-squad,false,false,true onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false +pluginInsights,experimental,@grafana/plugins-platform-backend,false,false,true panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 63257b6d738..0db4a887a6a 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2720,6 +2720,20 @@ "expression": "false" } }, + { + "metadata": { + "name": "pluginInsights", + "resourceVersion": "1761300628147", + "creationTimestamp": "2025-10-24T10:10:28Z" + }, + "spec": { + "description": "Show insights for plugins in the plugin details page", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "pluginInstallAPISync", diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 74a072ba054..aa5bc32f183 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -8,6 +8,7 @@ import { LocalPlugin, RemotePlugin, CatalogPluginDetails, + CatalogPluginInsights, Version, PluginVersion, InstancePlugin, @@ -47,6 +48,21 @@ export async function getPluginDetails(id: string): Promise { + if (!version) { + throw new Error('Version is required'); + } + try { + const insights = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins/${id}/versions/${version}/insights`); + return insights; + } catch (error) { + if (isFetchError(error)) { + error.isHandled = true; + } + throw error; + } +} + export async function getRemotePlugins(): Promise { try { const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, { diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx index da4eef2f0d4..0ffc93f8f77 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx @@ -62,10 +62,12 @@ const plugin: CatalogPlugin = { angularDetected: false, isFullyInstalled: true, accessControl: {}, + insights: { id: 1, name: 'test-plugin', version: '1.0.0', insights: [] }, }; jest.mock('../state/hooks', () => ({ useGetSingle: jest.fn(), + useGetPluginInsights: jest.fn(), useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }), useFetchDetailsStatus: () => ({ isLoading: false }), useIsRemotePluginsAvailable: () => false, diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx index e4252407ce5..2a7342e6be8 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx @@ -16,7 +16,7 @@ import { PluginDetailsPanel } from '../components/PluginDetailsPanel'; import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions'; -import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks'; +import { useGetSingle, useFetchStatus, useFetchDetailsStatus, useGetPluginInsights } from '../state/hooks'; import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning'; @@ -48,6 +48,8 @@ export function PluginDetailsPage({ }; const queryParams = new URLSearchParams(location.search); const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance + useGetPluginInsights(pluginId, plugin?.isInstalled ? plugin?.installedVersion : plugin?.latestVersion); + const isNarrowScreen = useMedia('(max-width: 600px)'); const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page'), isNarrowScreen); const { actions, info, subtitle } = usePluginPageExtensions(plugin); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx index eade37f559c..20787099842 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx @@ -1,11 +1,23 @@ +import userEvent from '@testing-library/user-event'; import { render, screen } from 'test/test-utils'; import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data'; +import { config } from '@grafana/runtime'; -import { CatalogPlugin } from '../types'; +import { CatalogPlugin, SCORE_LEVELS } from '../types'; import { PluginDetailsPanel } from './PluginDetailsPanel'; +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + featureToggles: { + pluginInsights: false, + }, + }, +})); + const mockPlugin: CatalogPlugin = { description: 'Test plugin description', downloads: 1000, @@ -185,4 +197,61 @@ describe('PluginDetailsPanel', () => { expect(regularLinks).toContainElement(raiseIssueLink); expect(regularLinks).not.toContainElement(websiteLink); }); + + it('should render plugin insights when plugin has insights', async () => { + config.featureToggles.pluginInsights = true; + const pluginWithInsights = { + ...mockPlugin, + insights: { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [ + { + name: 'security', + scoreValue: 90, + scoreLevel: SCORE_LEVELS.EXCELLENT, + items: [ + { + id: 'signature', + name: 'Signature verified', + level: 'ok' as const, + }, + ], + }, + ], + }, + }; + render(); + expect(screen.getByTestId('plugin-insights-container')).toBeInTheDocument(); + expect(screen.getByText('Plugin insights')).toBeInTheDocument(); + expect(screen.queryByText('Security')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Security')); + expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); + }); + + it('should not render plugin insights when plugin has no insights', () => { + const pluginWithoutInsights = { + ...mockPlugin, + insights: undefined, + }; + render(); + expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); + }); + + it('should not render plugin insights when insights array is empty', () => { + const pluginWithEmptyInsights = { + ...mockPlugin, + insights: { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [], + }, + }; + render(); + expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx index 00211b61c6e..aa8b6c792ef 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { PageInfoItem } from '@grafana/runtime/internal'; import { Stack, @@ -22,6 +22,8 @@ import { formatDate } from 'app/core/internationalization/dates'; import { CatalogPlugin } from '../types'; +import { PluginInsights } from './PluginInsights'; + type Props = { pluginExtentionsInfo: PageInfoItem[]; plugin: CatalogPlugin; width?: string }; export function PluginDetailsPanel(props: Props): React.ReactElement | null { @@ -69,6 +71,11 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null { return ( <> + {config.featureToggles.pluginInsights && plugin.insights && plugin.insights?.insights?.length > 0 && ( + + + + )} {pluginExtentionsInfo.map((infoItem, index) => { diff --git a/public/app/features/plugins/admin/components/PluginInsights.test.tsx b/public/app/features/plugins/admin/components/PluginInsights.test.tsx new file mode 100644 index 00000000000..efd064c7172 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginInsights.test.tsx @@ -0,0 +1,171 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test/test-utils'; + +import { CatalogPluginInsights, InsightLevel, SCORE_LEVELS } from '../types'; + +import { PluginInsights } from './PluginInsights'; + +const mockPluginInsights: CatalogPluginInsights = { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [ + { + name: 'security', + scoreValue: 90, + scoreLevel: SCORE_LEVELS.EXCELLENT, + items: [ + { + id: 'signature', + name: 'Signature verified', + description: 'Plugin signature is valid', + level: 'ok' as InsightLevel, + }, + { + id: 'trackingscripts', + name: 'No unsafe JavaScript detected', + level: 'good' as InsightLevel, + }, + ], + }, + { + name: 'quality', + scoreValue: 60, + scoreLevel: SCORE_LEVELS.FAIR, + items: [ + { + id: 'metadatavalid', + name: 'Metadata is valid', + level: 'ok' as InsightLevel, + }, + { + id: 'code-rules', + name: 'Missing code rules', + description: 'Plugin lacks comprehensive code rules', + level: 'warning' as InsightLevel, + }, + ], + }, + ], +}; + +const mockPluginInsightsWithPoorLevel: CatalogPluginInsights = { + id: 3, + name: 'test-plugin-poor', + version: '0.8.0', + insights: [ + { + name: 'quality', + scoreValue: 35, + scoreLevel: SCORE_LEVELS.POOR, + items: [ + { + id: 'legacy-platform', + name: 'Quality issues detected', + level: 'warning' as InsightLevel, + }, + ], + }, + ], +}; + +describe('PluginInsights', () => { + it('should render plugin insights section', () => { + render(); + const insightsSection = screen.getByTestId('plugin-insights-container'); + expect(insightsSection).toBeInTheDocument(); + expect(screen.getByText('Plugin insights')).toBeInTheDocument(); + }); + + it('should render all insight categories with test ids', () => { + render(); + expect(screen.getByTestId('plugin-insight-security')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-quality')).toBeInTheDocument(); + }); + + it('should render category names with test ids', () => { + render(); + const securityCategory = screen.getByTestId('plugin-insight-security'); + const qualityCategory = screen.getByTestId('plugin-insight-quality'); + + expect(securityCategory).toBeInTheDocument(); + expect(securityCategory).toHaveTextContent('Security'); + expect(qualityCategory).toBeInTheDocument(); + expect(qualityCategory).toHaveTextContent('Quality'); + }); + + it('should render individual insight items with test ids', async () => { + render(); + await userEvent.click(screen.getByText('Security')); + expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-item-trackingscripts')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Quality')); + expect(screen.getByTestId('plugin-insight-item-metadatavalid')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-item-code-rules')).toBeInTheDocument(); + }); + + it('should display correct icons for Excellent score level', () => { + render(); + + const securityCategory = screen.getByTestId('plugin-insight-security'); + const securityIcon = securityCategory.querySelector('[data-testid="excellent-icon"]'); + expect(securityIcon).toBeInTheDocument(); + }); + + it('should display correct icons for Poor score levels', () => { + // Test Poor level - should show exclamation-triangle + render(); + const poorCategory = screen.getByTestId('plugin-insight-quality'); + const poorIcon = poorCategory.querySelector('[data-testid="poor-icon"]'); + expect(poorIcon).toBeInTheDocument(); + }); + + it('should handle multiple items with different insight levels', async () => { + const multiLevelInsights: CatalogPluginInsights = { + id: 5, + name: 'multi-level-plugin', + version: '2.0.0', + insights: [ + { + name: 'quality', + scoreValue: 75, + scoreLevel: SCORE_LEVELS.GOOD, + items: [ + { + id: 'code-rules', + name: 'Info level item', + level: 'info' as InsightLevel, + }, + { + id: 'sdk-usage', + name: 'OK level item', + level: 'ok' as InsightLevel, + }, + { + id: 'jsMap', + name: 'Good level item', + level: 'good' as InsightLevel, + }, + { + id: 'gosec', + name: 'Warning level item', + level: 'warning' as InsightLevel, + }, + { + id: 'legacy-builder', + name: 'Danger level item', + level: 'danger' as InsightLevel, + }, + ], + }, + ], + }; + render(); + await userEvent.click(screen.getByText('Quality')); + expect(screen.getByText('Info level item')).toBeInTheDocument(); + expect(screen.getByText('OK level item')).toBeInTheDocument(); + expect(screen.getByText('Good level item')).toBeInTheDocument(); + expect(screen.getByText('Warning level item')).toBeInTheDocument(); + expect(screen.getByText('Danger level item')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginInsights.tsx b/public/app/features/plugins/admin/components/PluginInsights.tsx new file mode 100644 index 00000000000..805bcf926bf --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginInsights.tsx @@ -0,0 +1,140 @@ +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; +import { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { Stack, Text, TextLink, CollapsableSection, Tooltip, Icon, useStyles2, useTheme2 } from '@grafana/ui'; + +import { CatalogPluginInsights } from '../types'; + +type Props = { pluginInsights: CatalogPluginInsights | undefined }; + +const PLUGINS_INSIGHTS_OPENED_EVENT_NAME = 'plugins_insights_opened'; + +export function PluginInsights(props: Props): React.ReactElement | null { + const { pluginInsights } = props; + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const [openInsights, setOpenInsights] = useState>({}); + + const handleInsightToggle = (insightName: string, isOpen: boolean) => { + if (isOpen) { + reportInteraction(PLUGINS_INSIGHTS_OPENED_EVENT_NAME, { insight: insightName }); + } + setOpenInsights((prev) => ({ ...prev, [insightName]: isOpen })); + }; + + const tooltipInfo = ( + + + + + + All relevant signals are present and verified + + + + + + + + One or more signals are missing or need attention + + + +
+ + + Do you find Plugin Insights usefull? Please share your feedback{' '} + + here + + . + + +
+ ); + + return ( + <> + + + + Plugin insights + + + + + + {pluginInsights?.insights.map((insightItem, index) => { + return ( + + handleInsightToggle(insightItem.name, isOpen)} + label={ + + {insightItem.scoreLevel === 'Excellent' ? ( + + ) : ( + + )} + + {capitalize(insightItem.name)} + + + } + contentClassName={styles.pluginInsightsItems} + > + + {insightItem.items.map((item, idx) => ( + + + {item.level === 'good' ? ( + + ) : ( + + )} + + + {item.name} + + + ))} + + + + ); + })} + + + ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + pluginVersionDetails: css({ wordBreak: 'break-word' }), + pluginInsightsItems: css({ marginLeft: '26px', paddingTop: '0 !important' }), + pluginInsightsTooltipSeparator: css({ + border: 'none', + borderTop: `1px solid ${theme.colors.border.medium}`, + margin: `${theme.spacing(1)} 0`, + }), + }; +}; diff --git a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts index 9ced4f20a84..3625b687f7b 100644 --- a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts +++ b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts @@ -34,6 +34,7 @@ export default { updatedAt: '2021-08-25T15:03:49.000Z', version: '4.2.2', error: undefined, + insights: { id: 1, name: 'alexanderzobnin-zabbix-app', version: '4.2.2', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], @@ -381,6 +382,7 @@ export const datasourcePlugin = { angularDetected: false, isFullyInstalled: true, latestVersion: '1.20.0', + insights: { id: 2, name: 'grafana-redshift-datasource', version: '1.20.0', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], diff --git a/public/app/features/plugins/admin/mocks/mockHelpers.ts b/public/app/features/plugins/admin/mocks/mockHelpers.ts index 6034e8860e9..d6e04186f77 100644 --- a/public/app/features/plugins/admin/mocks/mockHelpers.ts +++ b/public/app/features/plugins/admin/mocks/mockHelpers.ts @@ -31,6 +31,9 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState 'plugins/fetchDetails': { status: RequestStatus.Fulfilled, }, + 'plugins/fetchPluginInsights': { + status: RequestStatus.Fulfilled, + }, }, // Backward compatibility plugins: [], @@ -75,6 +78,11 @@ export const mockPluginApis = ({ return Promise.resolve({ items: versions }); } + // Mock plugin insights - return empty insights to avoid API call errors + if (path.includes('/insights')) { + return Promise.resolve({ id: 1, name: '', version: '', insights: [] }); + } + // Mock local plugin settings (installed) if necessary if (local && path === `${API_ROOT}/${local.id}/settings`) { return Promise.resolve(local); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 6be68111dd5..e9cf2d9d40d 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -13,6 +13,7 @@ import { getPluginErrors, getLocalPlugins, getPluginDetails, + getPluginInsights, installPlugin, uninstallPlugin, getInstancePlugins, @@ -165,6 +166,22 @@ export const fetchDetails = createAsyncThunk, stri } ); +export const fetchPluginInsights = createAsyncThunk, { id: string; version?: string }>( + `${STATE_PREFIX}/fetchPluginInsights`, + async ({ id, version }, thunkApi) => { + try { + const insights = await getPluginInsights(id, version); + + return { + id, + changes: { insights }, + }; + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } + } +); + export const addPlugins = createAction(`${STATE_PREFIX}/addPlugins`); // 1. gets remote equivalents from the store (if there are any) @@ -265,7 +282,8 @@ export const panelPluginLoaded = createAction(`${STATE_PREFIX}/pane // TODO export const loadPanelPlugin = (id: string): ThunkResult> => { return async (dispatch, getStore) => { - let plugin = getStore().plugins.panels[id]; + const state = getStore(); + let plugin = state.plugins.panels[id]; if (!plugin) { plugin = await importPanelPlugin(id); diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index 2185ec99465..6eb47d7e1aa 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -6,7 +6,16 @@ import { useDispatch, useSelector } from 'app/types/store'; import { sortPlugins, Sorters, isPluginUpdatable } from '../helpers'; import { CatalogPlugin, PluginStatus } from '../types'; -import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal, unsetInstall } from './actions'; +import { + fetchAll, + fetchDetails, + fetchRemotePlugins, + install, + uninstall, + fetchAllLocal, + unsetInstall, + fetchPluginInsights, +} from './actions'; import { selectPlugins, selectById, @@ -44,13 +53,18 @@ export const useGetUpdatable = () => { }; }; -export const useGetSingle = (id: string): CatalogPlugin | undefined => { +export const useGetSingle = (id: string, version?: string): CatalogPlugin | undefined => { useFetchAll(); useFetchDetails(id); return useSelector((state) => selectById(state, id)); }; +export const useGetPluginInsights = (id: string, version: string | undefined): CatalogPlugin | undefined => { + useFetchPluginInsights(id, version); + return useSelector((state) => selectById(state, id)); +}; + export const useGetSingleLocalWithoutDetails = (id: string): CatalogPlugin | undefined => { useFetchAllLocal(); return useSelector((state) => selectById(state, id)); @@ -153,6 +167,17 @@ export const useFetchDetails = (id: string) => { }, [plugin]); // eslint-disable-line }; +export const useFetchPluginInsights = (id: string, version: string | undefined) => { + const dispatch = useDispatch(); + const plugin = useSelector((state) => selectById(state, id)); + const isNotFetching = !useSelector(selectIsRequestPending(fetchPluginInsights.typePrefix)); + const shouldFetch = isNotFetching && plugin && !plugin.insights && version; + + useEffect(() => { + shouldFetch && dispatch(fetchPluginInsights({ id, version })); + }, [plugin, version]); // eslint-disable-line +}; + export const useFetchDetailsLazy = () => { const dispatch = useDispatch(); diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index f2414a31405..e3d5bec5427 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -7,6 +7,7 @@ import { CatalogPlugin, ReducerState, RequestStatus } from '../types'; import { fetchDetails, + fetchPluginInsights, install, uninstall, loadPluginDashboards, @@ -63,6 +64,10 @@ const slice = createSlice({ .addCase(fetchDetails.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); }) + // Fetch Plugin Insights + .addCase(fetchPluginInsights.fulfilled, (state, action) => { + pluginsAdapter.updateOne(state.items, action.payload); + }) // Install .addCase(install.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 3cc66bba0b9..df4114101b4 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -55,6 +55,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata { updatedAt: string; installedVersion?: string; details?: CatalogPluginDetails; + insights?: CatalogPluginInsights; error?: PluginErrorCode; angularDetected?: boolean; // instance plugins may not be fully installed, which means a new instance @@ -90,6 +91,54 @@ export interface CatalogPluginDetails { screenshots?: Screenshots[] | null; } +export type InsightLevel = 'ok' | 'warning' | 'danger' | 'good' | 'info'; + +export const SCORE_LEVELS = { + EXCELLENT: 'Excellent', + GOOD: 'Good', + FAIR: 'Fair', + POOR: 'Poor', + CRITICAL: 'Critical', +} as const; + +export type ScoreLevel = (typeof SCORE_LEVELS)[keyof typeof SCORE_LEVELS]; + +export const INSIGHT_CATEGORIES = { + SECURITY: 'security', + QUALITY: 'quality', + PERFORMANCE: 'performance', +} as const; + +export const INSIGHT_LEVELS = { + GOOD: 'good', + OK: 'ok', + WARNING: 'warning', + DANGER: 'danger', + INFO: 'info', +} as const; + +export interface InsightItem { + id: string; + name: string; + description?: string; + level: InsightLevel; + link?: string; +} + +export interface InsightCategory { + name: string; + items: InsightItem[]; + scoreValue: number; + scoreLevel: ScoreLevel; +} + +export interface CatalogPluginInsights { + id: number; + name: string; + version: string; + insights: InsightCategory[]; +} + export interface CatalogPluginInfo { logos: { large: string; small: string }; keywords: string[]; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 00757c4251d..53abfa01d14 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11406,6 +11406,12 @@ "latestReleaseDate": "Latest release date:", "latestVersion": "Latest Version", "license": "License", + "moreDetails": "Do you find Plugin Insights usefull? Please share your feedback <2>here.", + "pluginInsights": { + "header": "Plugin insights" + }, + "pluginInsightsSuccessTooltip": "All relevant signals are present and verified", + "pluginInsightsWarningTooltip": "One or more signals are missing or need attention", "raiseAnIssue": "Raise an issue", "reportAbuse": "Report a concern", "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.",