diff --git a/public/app/features/plugins/admin/components/PluginSubtitle.test.tsx b/public/app/features/plugins/admin/components/PluginSubtitle.test.tsx
new file mode 100644
index 00000000000..cf2efa6fe54
--- /dev/null
+++ b/public/app/features/plugins/admin/components/PluginSubtitle.test.tsx
@@ -0,0 +1,120 @@
+import { render, screen } from '@testing-library/react';
+
+import { PluginSignatureStatus } from '@grafana/data';
+import { contextSrv } from 'app/core/core';
+
+import * as runtime from '../state/hooks';
+import { CatalogPlugin } from '../types';
+
+import { PluginSubtitle, registerPluginSubtitleExtension } from './PluginSubtitle';
+
+jest.mock('../state/hooks', () => ({
+ useIsRemotePluginsAvailable: jest.fn().mockReturnValue(true),
+ useInstallStatus: jest.fn().mockReturnValue({ error: null }),
+}));
+
+describe('PluginSubtitle', () => {
+ const basePlugin: CatalogPlugin = {
+ description: 'Test description',
+ downloads: 5,
+ id: 'test-plugin',
+ name: 'Test Plugin',
+ orgName: 'Test',
+ signature: PluginSignatureStatus.valid,
+ isInstalled: false,
+ hasUpdate: false,
+ isCore: false,
+ isDev: false,
+ details: {
+ links: [{ name: 'Website', url: 'http://test.com' }],
+ versions: [{ version: '1.0.0', grafanaDependency: '>=9.0.0', isCompatible: true, createdAt: '2020-01-01' }],
+ },
+ publishedAt: '2020-09-01',
+ updatedAt: '2021-06-28',
+ isEnterprise: false,
+ isDisabled: false,
+ isDeprecated: false,
+ isPublished: true,
+ isManaged: false,
+ isPreinstalled: { found: false, withVersion: false },
+ info: {
+ logos: {
+ small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small',
+ large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large',
+ },
+ keywords: ['test', 'plugin'],
+ },
+ popularity: 0,
+ };
+
+ beforeEach(() => {});
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders nothing when no plugin provided', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders plugin description', () => {
+ render();
+ expect(screen.getByText('Test description')).toBeInTheDocument();
+ });
+
+ it('renders links', () => {
+ render();
+ expect(screen.getByText('Website')).toHaveAttribute('href', 'http://test.com');
+ });
+
+ it('shows error alert when installation error exists', () => {
+ jest.spyOn(runtime, 'useInstallStatus').mockReturnValueOnce({
+ error: { message: 'Install failed', error: 'Details' },
+ isInstalling: false,
+ });
+ render();
+ expect(screen.getByText('Install failed')).toBeInTheDocument();
+ });
+
+ describe('warning when no permissions', () => {
+ beforeEach(() => {
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+ });
+
+ it('renders install control warning when conditions are met', () => {
+ const plugin = { ...basePlugin, isInstalled: false };
+ render();
+ expect(screen.queryByText(/permission to install/i)).toBeInTheDocument();
+ });
+
+ it('renders uninstall control warning when conditions are met', () => {
+ const plugin = { ...basePlugin, isInstalled: true };
+ render();
+ expect(screen.queryByText(/permission to uninstall/i)).toBeInTheDocument();
+ });
+ });
+
+ describe('no warning when has permissions', () => {
+ beforeEach(() => {
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
+ });
+
+ it('renders install control warning when conditions are met', () => {
+ const plugin = { ...basePlugin, isInstalled: false };
+ render();
+ expect(screen.queryByText(/permission to install/i)).not.toBeInTheDocument();
+ });
+
+ it('renders uninstall control warning when conditions are met', () => {
+ const plugin = { ...basePlugin, isInstalled: true };
+ render();
+ expect(screen.queryByText(/permission to uninstall/i)).not.toBeInTheDocument();
+ });
+ });
+ it('renders plugin subtitle extensions', () => {
+ const TestExtension = () =>
Extension Content
;
+ registerPluginSubtitleExtension(TestExtension);
+ render();
+ expect(screen.getByText('Extension Content')).toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/plugins/admin/components/PluginSubtitle.tsx b/public/app/features/plugins/admin/components/PluginSubtitle.tsx
index 53e604bd110..926d27ac198 100644
--- a/public/app/features/plugins/admin/components/PluginSubtitle.tsx
+++ b/public/app/features/plugins/admin/components/PluginSubtitle.tsx
@@ -3,7 +3,7 @@ import { Fragment } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
-import { Alert, useStyles2 } from '@grafana/ui';
+import { Alert, Stack, useStyles2 } from '@grafana/ui';
import { InstallControlsWarning } from '../components/InstallControls';
import { getLatestCompatibleVersion, hasInstallControlWarning } from '../helpers';
@@ -14,6 +14,14 @@ interface Props {
plugin?: CatalogPlugin;
}
+type PluginSubtitleExtension = (props: Props) => JSX.Element | null;
+
+const pluginSubtitleExtensions: PluginSubtitleExtension[] = [];
+
+export const registerPluginSubtitleExtension = (extension: PluginSubtitleExtension) => {
+ pluginSubtitleExtensions.push(extension);
+};
+
export const PluginSubtitle = ({ plugin }: Props) => {
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const styles = useStyles2(getStyles);
@@ -35,26 +43,33 @@ export const PluginSubtitle = ({ plugin }: Props) => {
{typeof errorInstalling === 'string' ? errorInstalling : errorInstalling.error}
)}
- {plugin?.description && {plugin?.description}
}
- {!config.featureToggles.pluginsDetailsRightPanel && !!plugin?.details?.links?.length && (
-
- {plugin.details.links.map((link, index) => (
-
- {index > 0 && ' | '}
-
- {link.name}
-
-
- ))}
-
- )}
- {hasInstallControlWarning(plugin, isRemotePluginsAvailable, latestCompatibleVersion) && (
-
- )}
+
+
+ {plugin?.description &&
{plugin?.description}
}
+ {!config.featureToggles.pluginsDetailsRightPanel && !!plugin?.details?.links?.length && (
+
+ {plugin.details.links.map((link, index) => (
+
+ {index > 0 && ' | '}
+
+ {link.name}
+
+
+ ))}
+
+ )}
+ {hasInstallControlWarning(plugin, isRemotePluginsAvailable, latestCompatibleVersion) && (
+
+ )}
+
+ {pluginSubtitleExtensions.map((extension) => {
+ return {extension({ plugin })};
+ })}
+
);
};
diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts
index c4bc6128a54..42848ce7863 100644
--- a/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts
+++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts
@@ -25,7 +25,7 @@ export async function shouldLoadPluginInFrontendSandbox({
pluginId,
}: SandboxEligibilityCheckParams): Promise {
// basic check if the plugin is eligible for the sandbox
- if (!(await isPluginFrontendSandboxElegible({ isAngular, pluginId }))) {
+ if (!(await isPluginFrontendSandboxEligible({ isAngular, pluginId }))) {
return false;
}
@@ -36,7 +36,7 @@ export async function shouldLoadPluginInFrontendSandbox({
* This is a basic check that checks if the plugin is eligible to run in the sandbox.
* It does not check if the plugin is actually enabled for the sandbox.
*/
-async function isPluginFrontendSandboxElegible({
+export async function isPluginFrontendSandboxEligible({
isAngular,
pluginId,
}: SandboxEligibilityCheckParams): Promise {
@@ -61,9 +61,14 @@ async function isPluginFrontendSandboxElegible({
return false;
}
- // don't run grafana-signed plugins in sandbox
- const pluginMeta = await getPluginSettings(pluginId);
- if (pluginMeta.signatureType === PluginSignatureType.grafana) {
+ try {
+ // don't run grafana-signed plugins in sandbox
+ const pluginMeta = await getPluginSettings(pluginId, { showErrorAlert: false });
+ if (pluginMeta.signatureType === PluginSignatureType.grafana) {
+ return false;
+ }
+ } catch (e) {
+ // this can fail if we are trying to fetch settings of a non-installed plugin
return false;
}