This reverts commit 1f4f2b4d7c.
This commit is contained in:
@@ -2868,6 +2868,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"public/app/features/plugins/admin/components/PluginDetailsPage.tsx": {
|
||||||
|
"@typescript-eslint/consistent-type-assertions": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
"public/app/features/plugins/admin/helpers.ts": {
|
"public/app/features/plugins/admin/helpers.ts": {
|
||||||
"no-restricted-syntax": {
|
"no-restricted-syntax": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -1189,11 +1189,6 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
onlyStoreActionSets?: boolean;
|
onlyStoreActionSets?: boolean;
|
||||||
/**
|
/**
|
||||||
* Show insights for plugins in the plugin details page
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
pluginInsights?: boolean;
|
|
||||||
/**
|
|
||||||
* Enables a new panel time settings drawer
|
* Enables a new panel time settings drawer
|
||||||
*/
|
*/
|
||||||
panelTimeSettings?: boolean;
|
panelTimeSettings?: boolean;
|
||||||
|
|||||||
@@ -1960,14 +1960,6 @@ var (
|
|||||||
Owner: identityAccessTeam,
|
Owner: identityAccessTeam,
|
||||||
Expression: "true",
|
Expression: "true",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "pluginInsights",
|
|
||||||
Description: "Show insights for plugins in the plugin details page",
|
|
||||||
Stage: FeatureStageExperimental,
|
|
||||||
FrontendOnly: true,
|
|
||||||
Owner: grafanaPluginsPlatformSquad,
|
|
||||||
Expression: "false",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "panelTimeSettings",
|
Name: "panelTimeSettings",
|
||||||
Description: "Enables a new panel time settings drawer",
|
Description: "Enables a new panel time settings drawer",
|
||||||
|
|||||||
Generated
-1
@@ -266,7 +266,6 @@ jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
|||||||
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||||
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
||||||
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
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
|
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
|
||||||
elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false
|
elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false
|
||||||
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
||||||
|
|||||||
|
-14
@@ -2693,20 +2693,6 @@
|
|||||||
"expression": "false"
|
"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": {
|
"metadata": {
|
||||||
"name": "pluginInstallAPISync",
|
"name": "pluginInstallAPISync",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
LocalPlugin,
|
LocalPlugin,
|
||||||
RemotePlugin,
|
RemotePlugin,
|
||||||
CatalogPluginDetails,
|
CatalogPluginDetails,
|
||||||
CatalogPluginInsights,
|
|
||||||
Version,
|
Version,
|
||||||
PluginVersion,
|
PluginVersion,
|
||||||
InstancePlugin,
|
InstancePlugin,
|
||||||
@@ -48,21 +47,6 @@ export async function getPluginDetails(id: string): Promise<CatalogPluginDetails
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPluginInsights(id: string, version: string | undefined): Promise<CatalogPluginInsights> {
|
|
||||||
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<RemotePlugin[]> {
|
export async function getRemotePlugins(): Promise<RemotePlugin[]> {
|
||||||
try {
|
try {
|
||||||
const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, {
|
const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, {
|
||||||
|
|||||||
@@ -62,12 +62,10 @@ const plugin: CatalogPlugin = {
|
|||||||
angularDetected: false,
|
angularDetected: false,
|
||||||
isFullyInstalled: true,
|
isFullyInstalled: true,
|
||||||
accessControl: {},
|
accessControl: {},
|
||||||
insights: { id: 1, name: 'test-plugin', version: '1.0.0', insights: [] },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('../state/hooks', () => ({
|
jest.mock('../state/hooks', () => ({
|
||||||
useGetSingle: jest.fn(),
|
useGetSingle: jest.fn(),
|
||||||
useGetPluginInsights: jest.fn(),
|
|
||||||
useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }),
|
useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }),
|
||||||
useFetchDetailsStatus: () => ({ isLoading: false }),
|
useFetchDetailsStatus: () => ({ isLoading: false }),
|
||||||
useIsRemotePluginsAvailable: () => false,
|
useIsRemotePluginsAvailable: () => false,
|
||||||
|
|||||||
@@ -16,19 +16,11 @@ import { PluginDetailsPanel } from '../components/PluginDetailsPanel';
|
|||||||
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
|
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
|
||||||
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
|
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
|
||||||
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
|
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
|
||||||
import { useGetSingle, useFetchStatus, useFetchDetailsStatus, useGetPluginInsights } from '../state/hooks';
|
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
|
||||||
import { PluginTabIds } from '../types';
|
import { PluginTabIds } from '../types';
|
||||||
|
|
||||||
import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning';
|
import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning';
|
||||||
|
|
||||||
function isPluginTabId(value: string | null): value is PluginTabIds {
|
|
||||||
if (!value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const validIds: string[] = Object.values(PluginTabIds);
|
|
||||||
return validIds.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
// The ID of the plugin
|
// The ID of the plugin
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -57,13 +49,12 @@ export function PluginDetailsPage({
|
|||||||
};
|
};
|
||||||
const queryParams = new URLSearchParams(location.search);
|
const queryParams = new URLSearchParams(location.search);
|
||||||
const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance
|
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 isNarrowScreen = useMedia('(max-width: 600px)');
|
||||||
const pageParam = queryParams.get('page');
|
const { navModel, activePageId } = usePluginDetailsTabs(
|
||||||
const pageId = pageParam && isPluginTabId(pageParam) ? pageParam : undefined;
|
plugin,
|
||||||
const { navModel, activePageId } = usePluginDetailsTabs(plugin, pageId, isNarrowScreen);
|
queryParams.get('page') as PluginTabIds,
|
||||||
|
isNarrowScreen
|
||||||
|
);
|
||||||
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
|
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
|
||||||
const { isLoading: isFetchLoading } = useFetchStatus();
|
const { isLoading: isFetchLoading } = useFetchStatus();
|
||||||
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
|
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { render, screen } from 'test/test-utils';
|
import { render, screen } from 'test/test-utils';
|
||||||
|
|
||||||
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
|
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { CatalogPlugin, SCORE_LEVELS } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
|
|
||||||
import { PluginDetailsPanel } from './PluginDetailsPanel';
|
import { PluginDetailsPanel } from './PluginDetailsPanel';
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
|
||||||
...jest.requireActual('@grafana/runtime'),
|
|
||||||
config: {
|
|
||||||
...jest.requireActual('@grafana/runtime').config,
|
|
||||||
featureToggles: {
|
|
||||||
pluginInsights: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockPlugin: CatalogPlugin = {
|
const mockPlugin: CatalogPlugin = {
|
||||||
description: 'Test plugin description',
|
description: 'Test plugin description',
|
||||||
downloads: 1000,
|
downloads: 1000,
|
||||||
@@ -197,61 +185,4 @@ describe('PluginDetailsPanel', () => {
|
|||||||
expect(regularLinks).toContainElement(raiseIssueLink);
|
expect(regularLinks).toContainElement(raiseIssueLink);
|
||||||
expect(regularLinks).not.toContainElement(websiteLink);
|
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(<PluginDetailsPanel plugin={pluginWithInsights} pluginExtentionsInfo={mockInfo} />);
|
|
||||||
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(<PluginDetailsPanel plugin={pluginWithoutInsights} pluginExtentionsInfo={mockInfo} />);
|
|
||||||
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(<PluginDetailsPanel plugin={pluginWithEmptyInsights} pluginExtentionsInfo={mockInfo} />);
|
|
||||||
expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { t, Trans } from '@grafana/i18n';
|
import { t, Trans } from '@grafana/i18n';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
import { PageInfoItem } from '@grafana/runtime/internal';
|
import { PageInfoItem } from '@grafana/runtime/internal';
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
@@ -22,8 +22,6 @@ import { formatDate } from 'app/core/internationalization/dates';
|
|||||||
|
|
||||||
import { CatalogPlugin } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
|
|
||||||
import { PluginInsights } from './PluginInsights';
|
|
||||||
|
|
||||||
type Props = { pluginExtentionsInfo: PageInfoItem[]; plugin: CatalogPlugin; width?: string };
|
type Props = { pluginExtentionsInfo: PageInfoItem[]; plugin: CatalogPlugin; width?: string };
|
||||||
|
|
||||||
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
|
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
|
||||||
@@ -71,11 +69,6 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack direction="column" gap={3} shrink={0} grow={0} width={width} data-testid="plugin-details-panel">
|
<Stack direction="column" gap={3} shrink={0} grow={0} width={width} data-testid="plugin-details-panel">
|
||||||
{config.featureToggles.pluginInsights && plugin.insights && plugin.insights?.insights?.length > 0 && (
|
|
||||||
<Box borderRadius="lg" padding={2} borderColor="medium" borderStyle="solid">
|
|
||||||
<PluginInsights pluginInsights={plugin.insights} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box borderRadius="lg" padding={2} borderColor="medium" borderStyle="solid">
|
<Box borderRadius="lg" padding={2} borderColor="medium" borderStyle="solid">
|
||||||
<Stack direction="column" gap={2}>
|
<Stack direction="column" gap={2}>
|
||||||
{pluginExtentionsInfo.map((infoItem, index) => {
|
{pluginExtentionsInfo.map((infoItem, index) => {
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
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(<PluginInsights pluginInsights={mockPluginInsights} />);
|
|
||||||
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(<PluginInsights pluginInsights={mockPluginInsights} />);
|
|
||||||
expect(screen.getByTestId('plugin-insight-security')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('plugin-insight-quality')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render category names with test ids', () => {
|
|
||||||
render(<PluginInsights pluginInsights={mockPluginInsights} />);
|
|
||||||
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(<PluginInsights pluginInsights={mockPluginInsights} />);
|
|
||||||
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(<PluginInsights pluginInsights={mockPluginInsights} />);
|
|
||||||
|
|
||||||
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(<PluginInsights pluginInsights={mockPluginInsightsWithPoorLevel} />);
|
|
||||||
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(<PluginInsights pluginInsights={multiLevelInsights} />);
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const handleInsightToggle = (insightName: string, isOpen: boolean) => {
|
|
||||||
if (isOpen) {
|
|
||||||
reportInteraction(PLUGINS_INSIGHTS_OPENED_EVENT_NAME, { insight: insightName });
|
|
||||||
}
|
|
||||||
setOpenInsights((prev) => ({ ...prev, [insightName]: isOpen }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const tooltipInfo = (
|
|
||||||
<Stack direction="column" gap={0.5}>
|
|
||||||
<Stack direction="row" alignItems="center">
|
|
||||||
<Icon name="check-circle" size="md" color={theme.colors.success.main} />
|
|
||||||
<Text color="primary" variant="body">
|
|
||||||
<Trans i18nKey="plugins.details.labels.pluginInsightsSuccessTooltip">
|
|
||||||
All relevant signals are present and verified
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" alignItems="center">
|
|
||||||
<Icon name="exclamation-triangle" size="md" />
|
|
||||||
<Text color="primary" variant="body">
|
|
||||||
<Trans i18nKey="plugins.details.labels.pluginInsightsWarningTooltip">
|
|
||||||
One or more signals are missing or need attention
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<hr className={styles.pluginInsightsTooltipSeparator} />
|
|
||||||
<Text color="secondary" variant="body">
|
|
||||||
<Trans i18nKey="plugins.details.labels.moreDetails">
|
|
||||||
Do you find Plugin Insights usefull? Please share your feedback{' '}
|
|
||||||
<TextLink href="https://forms.gle/1ZVLbecyQ8aY9mDYA" external>
|
|
||||||
here
|
|
||||||
</TextLink>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack direction="column" gap={0.5} shrink={0} grow={0} data-testid="plugin-insights-container">
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
||||||
<Text color="secondary" variant="h6" data-testid="plugin-insights-header">
|
|
||||||
<Trans i18nKey="plugins.details.labels.pluginInsights.header">Plugin insights</Trans>
|
|
||||||
</Text>
|
|
||||||
<Tooltip content={tooltipInfo} placement="right-end" interactive>
|
|
||||||
<Icon name="info-circle" size="md" />
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
{pluginInsights?.insights.map((insightItem, index) => {
|
|
||||||
return (
|
|
||||||
<Stack key={index} wrap direction="column" gap={1}>
|
|
||||||
<CollapsableSection
|
|
||||||
isOpen={openInsights[insightItem.name] ?? false}
|
|
||||||
onToggle={(isOpen) => handleInsightToggle(insightItem.name, isOpen)}
|
|
||||||
label={
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={1}
|
|
||||||
alignItems="center"
|
|
||||||
data-testid={`plugin-insight-${insightItem.name.toLowerCase()}`}
|
|
||||||
>
|
|
||||||
{insightItem.scoreLevel === 'Excellent' ? (
|
|
||||||
<Icon
|
|
||||||
name="check-circle"
|
|
||||||
size="lg"
|
|
||||||
color={theme.colors.success.main}
|
|
||||||
data-testid="excellent-icon"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Icon name="exclamation-triangle" size="lg" data-testid="poor-icon" />
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
color="primary"
|
|
||||||
variant="body"
|
|
||||||
data-testid={`plugin-insight-color-${insightItem.name.toLowerCase()}`}
|
|
||||||
>
|
|
||||||
{capitalize(insightItem.name)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
contentClassName={styles.pluginInsightsItems}
|
|
||||||
>
|
|
||||||
<Stack direction="column" gap={1}>
|
|
||||||
{insightItem.items.map((item, idx) => (
|
|
||||||
<Stack key={idx} direction="row" gap={1} alignItems="flex-start">
|
|
||||||
<span>
|
|
||||||
{item.level === 'good' ? (
|
|
||||||
<Icon name="check-circle" size="sm" color={theme.colors.success.main} />
|
|
||||||
) : (
|
|
||||||
<Icon name="exclamation-triangle" size="sm" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<Text color="secondary" variant="body" data-testid={`plugin-insight-item-${item.id}`}>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</CollapsableSection>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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`,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -34,7 +34,6 @@ export default {
|
|||||||
updatedAt: '2021-08-25T15:03:49.000Z',
|
updatedAt: '2021-08-25T15:03:49.000Z',
|
||||||
version: '4.2.2',
|
version: '4.2.2',
|
||||||
error: undefined,
|
error: undefined,
|
||||||
insights: { id: 1, name: 'alexanderzobnin-zabbix-app', version: '4.2.2', insights: [] },
|
|
||||||
details: {
|
details: {
|
||||||
grafanaDependency: '>=8.0.0',
|
grafanaDependency: '>=8.0.0',
|
||||||
pluginDependencies: [],
|
pluginDependencies: [],
|
||||||
@@ -382,7 +381,6 @@ export const datasourcePlugin = {
|
|||||||
angularDetected: false,
|
angularDetected: false,
|
||||||
isFullyInstalled: true,
|
isFullyInstalled: true,
|
||||||
latestVersion: '1.20.0',
|
latestVersion: '1.20.0',
|
||||||
insights: { id: 2, name: 'grafana-redshift-datasource', version: '1.20.0', insights: [] },
|
|
||||||
details: {
|
details: {
|
||||||
grafanaDependency: '>=8.0.0',
|
grafanaDependency: '>=8.0.0',
|
||||||
pluginDependencies: [],
|
pluginDependencies: [],
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState
|
|||||||
'plugins/fetchDetails': {
|
'plugins/fetchDetails': {
|
||||||
status: RequestStatus.Fulfilled,
|
status: RequestStatus.Fulfilled,
|
||||||
},
|
},
|
||||||
'plugins/fetchPluginInsights': {
|
|
||||||
status: RequestStatus.Fulfilled,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Backward compatibility
|
// Backward compatibility
|
||||||
plugins: [],
|
plugins: [],
|
||||||
@@ -78,11 +75,6 @@ export const mockPluginApis = ({
|
|||||||
return Promise.resolve({ items: versions });
|
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
|
// Mock local plugin settings (installed) if necessary
|
||||||
if (local && path === `${API_ROOT}/${local.id}/settings`) {
|
if (local && path === `${API_ROOT}/${local.id}/settings`) {
|
||||||
return Promise.resolve(local);
|
return Promise.resolve(local);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
getPluginErrors,
|
getPluginErrors,
|
||||||
getLocalPlugins,
|
getLocalPlugins,
|
||||||
getPluginDetails,
|
getPluginDetails,
|
||||||
getPluginInsights,
|
|
||||||
installPlugin,
|
installPlugin,
|
||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
getInstancePlugins,
|
getInstancePlugins,
|
||||||
@@ -166,22 +165,6 @@ export const fetchDetails = createAsyncThunk<Update<CatalogPlugin, string>, stri
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchPluginInsights = createAsyncThunk<Update<CatalogPlugin, string>, { 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<CatalogPlugin[]>(`${STATE_PREFIX}/addPlugins`);
|
export const addPlugins = createAction<CatalogPlugin[]>(`${STATE_PREFIX}/addPlugins`);
|
||||||
|
|
||||||
// 1. gets remote equivalents from the store (if there are any)
|
// 1. gets remote equivalents from the store (if there are any)
|
||||||
@@ -282,8 +265,7 @@ export const panelPluginLoaded = createAction<PanelPlugin>(`${STATE_PREFIX}/pane
|
|||||||
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
|
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
|
||||||
export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> => {
|
export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> => {
|
||||||
return async (dispatch, getStore) => {
|
return async (dispatch, getStore) => {
|
||||||
const state = getStore();
|
let plugin = getStore().plugins.panels[id];
|
||||||
let plugin = state.plugins.panels[id];
|
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
plugin = await importPanelPlugin(id);
|
plugin = await importPanelPlugin(id);
|
||||||
|
|||||||
@@ -6,16 +6,7 @@ import { useDispatch, useSelector } from 'app/types/store';
|
|||||||
import { sortPlugins, Sorters, isPluginUpdatable } from '../helpers';
|
import { sortPlugins, Sorters, isPluginUpdatable } from '../helpers';
|
||||||
import { CatalogPlugin, PluginStatus } from '../types';
|
import { CatalogPlugin, PluginStatus } from '../types';
|
||||||
|
|
||||||
import {
|
import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal, unsetInstall } from './actions';
|
||||||
fetchAll,
|
|
||||||
fetchDetails,
|
|
||||||
fetchRemotePlugins,
|
|
||||||
install,
|
|
||||||
uninstall,
|
|
||||||
fetchAllLocal,
|
|
||||||
unsetInstall,
|
|
||||||
fetchPluginInsights,
|
|
||||||
} from './actions';
|
|
||||||
import {
|
import {
|
||||||
selectPlugins,
|
selectPlugins,
|
||||||
selectById,
|
selectById,
|
||||||
@@ -53,18 +44,13 @@ export const useGetUpdatable = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGetSingle = (id: string, version?: string): CatalogPlugin | undefined => {
|
export const useGetSingle = (id: string): CatalogPlugin | undefined => {
|
||||||
useFetchAll();
|
useFetchAll();
|
||||||
useFetchDetails(id);
|
useFetchDetails(id);
|
||||||
|
|
||||||
return useSelector((state) => selectById(state, 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 => {
|
export const useGetSingleLocalWithoutDetails = (id: string): CatalogPlugin | undefined => {
|
||||||
useFetchAllLocal();
|
useFetchAllLocal();
|
||||||
return useSelector((state) => selectById(state, id));
|
return useSelector((state) => selectById(state, id));
|
||||||
@@ -167,17 +153,6 @@ export const useFetchDetails = (id: string) => {
|
|||||||
}, [plugin]); // eslint-disable-line
|
}, [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 = () => {
|
export const useFetchDetailsLazy = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { CatalogPlugin, ReducerState, RequestStatus } from '../types';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
fetchDetails,
|
fetchDetails,
|
||||||
fetchPluginInsights,
|
|
||||||
install,
|
install,
|
||||||
uninstall,
|
uninstall,
|
||||||
loadPluginDashboards,
|
loadPluginDashboards,
|
||||||
@@ -64,10 +63,6 @@ const slice = createSlice({
|
|||||||
.addCase(fetchDetails.fulfilled, (state, action) => {
|
.addCase(fetchDetails.fulfilled, (state, action) => {
|
||||||
pluginsAdapter.updateOne(state.items, action.payload);
|
pluginsAdapter.updateOne(state.items, action.payload);
|
||||||
})
|
})
|
||||||
// Fetch Plugin Insights
|
|
||||||
.addCase(fetchPluginInsights.fulfilled, (state, action) => {
|
|
||||||
pluginsAdapter.updateOne(state.items, action.payload);
|
|
||||||
})
|
|
||||||
// Install
|
// Install
|
||||||
.addCase(install.fulfilled, (state, action) => {
|
.addCase(install.fulfilled, (state, action) => {
|
||||||
pluginsAdapter.updateOne(state.items, action.payload);
|
pluginsAdapter.updateOne(state.items, action.payload);
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
installedVersion?: string;
|
installedVersion?: string;
|
||||||
details?: CatalogPluginDetails;
|
details?: CatalogPluginDetails;
|
||||||
insights?: CatalogPluginInsights;
|
|
||||||
error?: PluginErrorCode;
|
error?: PluginErrorCode;
|
||||||
angularDetected?: boolean;
|
angularDetected?: boolean;
|
||||||
// instance plugins may not be fully installed, which means a new instance
|
// instance plugins may not be fully installed, which means a new instance
|
||||||
@@ -91,54 +90,6 @@ export interface CatalogPluginDetails {
|
|||||||
screenshots?: Screenshots[] | null;
|
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 {
|
export interface CatalogPluginInfo {
|
||||||
logos: { large: string; small: string };
|
logos: { large: string; small: string };
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
|
|||||||
@@ -11390,12 +11390,6 @@
|
|||||||
"latestReleaseDate": "Latest release date:",
|
"latestReleaseDate": "Latest release date:",
|
||||||
"latestVersion": "Latest Version",
|
"latestVersion": "Latest Version",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"moreDetails": "Do you find Plugin Insights usefull? Please share your feedback <2>here</2>.",
|
|
||||||
"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",
|
"raiseAnIssue": "Raise an issue",
|
||||||
"reportAbuse": "Report a concern",
|
"reportAbuse": "Report a concern",
|
||||||
"reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.",
|
"reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.",
|
||||||
|
|||||||
Reference in New Issue
Block a user