({
version: v.version,
createdAt: v.createdAt,
+ updatedAt: v.updatedAt,
isCompatible: v.isCompatible,
grafanaDependency: v.grafanaDependency,
angularDetected: v.angularDetected,
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
index 4f1bde85393..ad97440a183 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
@@ -57,6 +57,7 @@ const plugin: CatalogPlugin = {
],
grafanaDependency: '>=9.0.0',
statusContext: 'stable',
+ changelog: 'Test changelog',
},
angularDetected: false,
isFullyInstalled: true,
@@ -154,4 +155,28 @@ describe('PluginDetailsPage', () => {
render();
expect(screen.getByRole('tab', { name: 'Data source connections' })).toBeVisible();
});
+
+ it('should not show version and changelog tabs when plugin is core', () => {
+ mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true });
+ render();
+ expect(screen.queryByRole('tab', { name: 'Version history' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('tab', { name: 'Changelog' })).not.toBeInTheDocument();
+ });
+
+ it('should not show last version in plugin details panel when plugin is core', () => {
+ config.featureToggles.pluginsDetailsRightPanel = true;
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: query !== '(max-width: 600px)',
+ media: query,
+ onchange: null,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ }));
+
+ mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true, latestVersion: '1.2.0' });
+
+ render();
+ expect(screen.queryByText('Latest Version:')).not.toBeInTheDocument();
+ });
});
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
index 2169f41e564..3c1e23d36d2 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
@@ -105,7 +105,6 @@ describe('PluginDetailsPanel', () => {
it('should render report abuse section for non-core plugins', () => {
render();
expect(screen.getByText('Report a concern')).toBeInTheDocument();
- expect(screen.getByText('Contact Grafana Labs')).toBeInTheDocument();
});
it('should not render report abuse section for core plugins', () => {
@@ -117,6 +116,6 @@ describe('PluginDetailsPanel', () => {
it('should respect custom width prop', () => {
render();
const panel = screen.getByTestId('plugin-details-panel');
- expect(panel).toHaveStyle({ maxWidth: '300px' });
+ expect(panel).toHaveStyle({ width: '300px' });
});
});
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
index 95b963097b7..7d7612dc814 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
@@ -1,8 +1,22 @@
import { css } from '@emotion/css';
+import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
+import { reportInteraction } from '@grafana/runtime';
import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage';
-import { Stack, Text, LinkButton, Box, TextLink, useStyles2 } from '@grafana/ui';
+import {
+ Stack,
+ Text,
+ LinkButton,
+ Box,
+ TextLink,
+ CollapsableSection,
+ Tooltip,
+ Icon,
+ Modal,
+ Button,
+ useStyles2,
+} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { formatDate } from 'app/core/internationalization/dates';
@@ -16,73 +30,203 @@ type Props = {
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
const { pluginExtentionsInfo, plugin, width = '250px' } = props;
+ const [reportAbuseModalOpen, setReportAbuseModalOpen] = useState(false);
+
+ const normalizeURL = (url: string | undefined) => url?.replace(/\/$/, '');
+
+ const customLinks = plugin.details?.links?.filter((link) => {
+ const customLinksFiltered = ![plugin.url, plugin.details?.licenseUrl, plugin.details?.documentationUrl]
+ .map(normalizeURL)
+ .includes(normalizeURL(link.url));
+ return customLinksFiltered;
+ });
+ const shouldRenderLinks = plugin.url || plugin.details?.licenseUrl || plugin.details?.documentationUrl;
+
const styles = useStyles2(getStyles);
- return (
-
-
-
- {pluginExtentionsInfo.map((infoItem, index) => {
- return (
-
- {infoItem.label + ':'}
- {infoItem.value}
-
- );
- })}
- {plugin.updatedAt && (
-
-
- Last updated:
- {' '}
- {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })}
-
- )}
- {plugin?.details?.lastCommitDate && (
-
-
- Last commit date:
- {' '}
-
- {formatDate(new Date(plugin.details.lastCommitDate), {
- day: 'numeric',
- month: 'short',
- year: 'numeric',
- })}
-
-
- )}
-
-
+ const onClickReportConcern = (pluginId: string) => {
+ setReportAbuseModalOpen(true);
+ reportInteraction('plugin_detail_report_concern', {
+ plugin_id: pluginId,
+ });
+ };
- {plugin?.details?.links && plugin.details?.links?.length > 0 && (
+ return (
+ <>
+
-
- Links
-
- {plugin.details.links.map((link, index) => (
-
- {link.name}
-
- ))}
+ {pluginExtentionsInfo.map((infoItem, index) => {
+ return (
+
+ {infoItem.label + ':'}
+ {infoItem.value}
+
+ );
+ })}
+ {plugin.updatedAt && (
+
+
+ Last updated:
+ {' '}
+
+ {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })}
+
+
+ )}
+ {plugin?.details?.lastCommitDate && (
+
+
+ Last commit date:
+ {' '}
+
+ {formatDate(new Date(plugin.details.lastCommitDate), {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ })}
+
+
+ )}
- )}
-
- {!plugin?.isCore && (
-
-
-
- Report a concern
+ {shouldRenderLinks && (
+ <>
+
+
+ {plugin.url && (
+
+ Repository
+
+ )}
+ {plugin.raiseAnIssueUrl && (
+
+ Raise an issue
+
+ )}
+ {plugin.details?.licenseUrl && (
+
+ License
+
+ )}
+ {plugin.details?.documentationUrl && (
+
+ Documentation
+
+ )}
+
+
+ >
+ )}
+ {customLinks && customLinks?.length > 0 && (
+
+
+
+ Custom links
+
+
+ These links are provided by the plugin developer to offer additional, developer-specific
+ resources and information
+
+ }
+ placement="right-end"
+ >
+
+
+
+ }
+ >
+
+ {customLinks.map((link, index) => (
+
+ {link.name}
+
+ ))}
+
+
+
+ )}
+ {!plugin?.isCore && (
+
+
+
+ Report a concern
+
+
+ Report issues related to malicious or harmful plugins directly to Grafana Labs.
+
+ }
+ placement="right-end"
+ >
+
+
+
+ }
+ >
+
+
+
+
+
+ )}
+
+ {reportAbuseModalOpen && (
+ Report a plugin concern}
+ isOpen
+ onDismiss={() => setReportAbuseModalOpen(false)}
+ >
+
+
+
+ This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email
+ us at:{' '}
+
+ integrations@grafana.com
+
+
+
+ Note: For general plugin issues like bugs or feature requests, please contact the plugin author using
+ the provided links.{' '}
+
-
- Contact Grafana Labs
-
-
+
+
+
+
+
)}
-
+ >
);
}
diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx
index aaf98fd183b..2e79b0d43c1 100644
--- a/public/app/features/plugins/admin/components/VersionList.tsx
+++ b/public/app/features/plugins/admin/components/VersionList.tsx
@@ -96,7 +96,7 @@ export const VersionList = ({ pluginId, versions = [], installedVersion, disable
{/* Last updated */}
- {dateTimeFormatTimeAgo(version.createdAt)}
+ {dateTimeFormatTimeAgo(version.updatedAt || version.createdAt)}
|
{/* Dependency */}
{version.grafanaDependency || 'N/A'} |
diff --git a/public/app/features/plugins/admin/helpers.test.ts b/public/app/features/plugins/admin/helpers.test.ts
index b47c5bf774b..b1b109f2ca5 100644
--- a/public/app/features/plugins/admin/helpers.test.ts
+++ b/public/app/features/plugins/admin/helpers.test.ts
@@ -217,6 +217,7 @@ describe('Plugins/Helpers', () => {
updatedAt: '2021-05-18T14:53:01.000Z',
isFullyInstalled: false,
angularDetected: false,
+ url: 'https://github.com/alexanderzobnin/grafana-zabbix',
});
});
@@ -354,6 +355,7 @@ describe('Plugins/Helpers', () => {
installedVersion: '4.2.2',
isFullyInstalled: true,
angularDetected: false,
+ url: 'https://github.com/alexanderzobnin/grafana-zabbix',
});
});
diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts
index 172a32e088f..2f7814cc729 100644
--- a/public/app/features/plugins/admin/helpers.ts
+++ b/public/app/features/plugins/admin/helpers.ts
@@ -121,6 +121,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
signatureType,
versionSignatureType,
versionSignedByOrgName,
+ url,
+ raiseAnIssueUrl,
} = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(typeCode);
@@ -158,6 +160,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
angularDetected,
isFullyInstalled: isDisabled,
latestVersion: plugin.version,
+ url,
+ raiseAnIssueUrl,
};
}
@@ -174,6 +178,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
hasUpdate,
accessControl,
angularDetected,
+ raiseAnIssueUrl,
} = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(type);
@@ -208,6 +213,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
isFullyInstalled: true,
iam: plugin.iam,
latestVersion: plugin.latestVersion,
+ raiseAnIssueUrl,
};
}
@@ -271,6 +277,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
isFullyInstalled: Boolean(local) || isDisabled,
iam: local?.iam,
latestVersion: local?.latestVersion || remote?.version || '',
+ url: remote?.url || '',
+ raiseAnIssueUrl: remote?.raiseAnIssueUrl || local?.raiseAnIssueUrl,
};
}
diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx
index e9c50269d15..d9f7c2e1375 100644
--- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx
+++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx
@@ -42,7 +42,8 @@ export const usePluginDetailsTabs = (
const navModelChildren = useMemo(() => {
const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin);
const navModelChildren: NavModelItem[] = [];
- if (isPublished) {
+ // currently the versions available of core plugins are not consistent
+ if (isPublished && !plugin?.isCore) {
navModelChildren.push({
text: PluginTabLabels.VERSIONS,
id: PluginTabIds.VERSIONS,
@@ -51,7 +52,8 @@ export const usePluginDetailsTabs = (
active: PluginTabIds.VERSIONS === currentPageId,
});
}
- if (isPublished && plugin?.details?.changelog) {
+ // currently there is not changelog available for core plugins
+ if (isPublished && plugin?.details?.changelog && !plugin.isCore) {
navModelChildren.push({
text: PluginTabLabels.CHANGELOG,
id: PluginTabIds.CHANGELOG,
diff --git a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx
index 1bb1334e1ef..2c9124f1860 100644
--- a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx
+++ b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx
@@ -53,7 +53,10 @@ export const usePluginInfo = (plugin?: CatalogPlugin): PageInfoItem[] => {
latestVersionValue = latestVersion;
}
- addInfo('latestVersion', latestVersionValue);
+ // latest versions of core plugins are not consistent
+ if (!plugin.isCore) {
+ addInfo('latestVersion', latestVersionValue);
+ }
}
if (Boolean(plugin.orgName)) {
diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts
index 10e2636b2ef..f3d5783ae3c 100644
--- a/public/app/features/plugins/admin/types.ts
+++ b/public/app/features/plugins/admin/types.ts
@@ -64,6 +64,8 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
isUpdatingFromInstance?: boolean;
iam?: IdentityAccessManagement;
isProvisioned?: boolean;
+ url?: string;
+ raiseAnIssueUrl?: string;
}
export interface CatalogPluginDetails {
@@ -79,6 +81,8 @@ export interface CatalogPluginDetails {
iam?: IdentityAccessManagement;
changelog?: string;
lastCommitDate?: string;
+ licenseUrl?: string;
+ documentationUrl?: string;
signatureType?: PluginSignatureType;
signature?: PluginSignatureStatus;
}
@@ -143,6 +147,9 @@ export type RemotePlugin = {
versionStatus: string;
angularDetected?: boolean;
lastCommitDate?: string;
+ licenseUrl?: string;
+ documentationUrl?: string;
+ raiseAnIssueUrl?: string;
};
// The available status codes on GCOM are available here:
@@ -190,6 +197,7 @@ export type LocalPlugin = WithAccessControlMetadata & {
dependencies: PluginDependencies;
angularDetected: boolean;
iam?: IdentityAccessManagement;
+ raiseAnIssueUrl?: string;
};
interface IdentityAccessManagement {
@@ -216,6 +224,7 @@ export interface Build {
export interface Version {
version: string;
createdAt: string;
+ updatedAt?: string;
isCompatible: boolean;
grafanaDependency: string | null;
angularDetected?: boolean;
diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx
index 84736aed8de..c9e5ec616be 100644
--- a/public/app/features/plugins/components/AppRootPage.test.tsx
+++ b/public/app/features/plugins/components/AppRootPage.test.tsx
@@ -12,6 +12,7 @@ import { Echo } from 'app/core/services/echo/Echo';
import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext';
import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
import { getPluginSettings } from '../pluginSettings';
@@ -93,6 +94,7 @@ function renderUnderRouter(page = '') {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
const pagePath = page ? `/${page}` : '';
const route = {
diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx
index f0d6bd5329e..95a280027df 100644
--- a/public/app/features/plugins/components/AppRootPage.tsx
+++ b/public/app/features/plugins/components/AppRootPage.tsx
@@ -29,6 +29,7 @@ import {
useAddedLinksRegistry,
useAddedComponentsRegistry,
useExposedComponentsRegistry,
+ useAddedFunctionsRegistry,
} from '../extensions/ExtensionRegistriesContext';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
@@ -60,6 +61,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const addedLinksRegistry = useAddedLinksRegistry();
const addedComponentsRegistry = useAddedComponentsRegistry();
const exposedComponentsRegistry = useExposedComponentsRegistry();
+ const addedFunctionsRegistry = useAddedFunctionsRegistry();
const location = useLocation();
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
const currentUrl = config.appSubUrl + location.pathname + location.search;
@@ -104,6 +106,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
addedLinksRegistry: addedLinksRegistry.readOnly(),
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
+ addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
}}
>
(undefined);
export const AddedComponentsRegistryContext = createContext(undefined);
+export const AddedFunctionsRegistryContext = createContext(undefined);
export const ExposedComponentsRegistryContext = createContext(undefined);
export function useAddedLinksRegistry(): AddedLinksRegistry {
@@ -31,6 +33,14 @@ export function useAddedComponentsRegistry(): AddedComponentsRegistry {
return context;
}
+export function useAddedFunctionsRegistry(): AddedFunctionsRegistry {
+ const context = useContext(AddedFunctionsRegistryContext);
+ if (!context) {
+ throw new Error('No `AddedFunctionsRegistry` found.');
+ }
+ return context;
+}
+
export function useExposedComponentsRegistry(): ExposedComponentsRegistry {
const context = useContext(ExposedComponentsRegistryContext);
if (!context) {
@@ -46,9 +56,11 @@ export const ExtensionRegistriesProvider = ({
return (
-
- {children}
-
+
+
+ {children}
+
+
);
diff --git a/public/app/features/plugins/extensions/errors.ts b/public/app/features/plugins/extensions/errors.ts
index 39c65df0f8e..cb9d22e7b46 100644
--- a/public/app/features/plugins/extensions/errors.ts
+++ b/public/app/features/plugins/extensions/errors.ts
@@ -8,6 +8,8 @@ export const TITLE_MISSING = 'Title is missing.';
export const DESCRIPTION_MISSING = 'Description is missing.';
+export const INVALID_EXTENSION_FUNCTION = 'The "fn" argument is invalid, it should be a function.';
+
export const INVALID_CONFIGURE_FUNCTION = 'The "configure" function is invalid. It should be a function.';
export const INVALID_PATH_OR_ON_CLICK = 'Either "path" or "onClick" is required.';
@@ -33,6 +35,9 @@ export const TITLE_NOT_MATCHING_META_INFO = 'The "title" doesn\'t match the titl
export const ADDED_LINK_META_INFO_MISSING =
'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
+export const ADDED_FUNCTION_META_INFO_MISSING =
+ 'The extension was not recorded in the plugin.json. Added function extensions must be listed in the section "extensions.addedFunction[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
+
export const DESCRIPTION_NOT_MATCHING_META_INFO =
'The "description" doesn\'t match the description recorded in plugin.json.';
diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts
index a47292c8497..30f5865b259 100644
--- a/public/app/features/plugins/extensions/getPluginExtensions.ts
+++ b/public/app/features/plugins/extensions/getPluginExtensions.ts
@@ -1,8 +1,8 @@
import { isString } from 'lodash';
import {
- type PluginExtension,
PluginExtensionTypes,
+ type PluginExtension,
type PluginExtensionLink,
type PluginExtensionComponent,
} from '@grafana/data';
diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
index e82c48dcee0..5a18e222431 100644
--- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
+++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
@@ -52,6 +52,7 @@ describe('AddedComponentsRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts
new file mode 100644
index 00000000000..ae0addc52c7
--- /dev/null
+++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts
@@ -0,0 +1,677 @@
+import { firstValueFrom } from 'rxjs';
+
+import { PluginLoadingStrategy } from '@grafana/data';
+import { config } from '@grafana/runtime';
+
+import { log } from '../logs/log';
+import { resetLogMock } from '../logs/testUtils';
+import { isGrafanaDevMode } from '../utils';
+
+import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
+import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
+
+jest.mock('../utils', () => ({
+ ...jest.requireActual('../utils'),
+
+ // Manually set the dev mode to false
+ // (to make sure that by default we are testing a production scneario)
+ isGrafanaDevMode: jest.fn().mockReturnValue(false),
+}));
+
+jest.mock('../logs/log', () => {
+ const { createLogMock } = jest.requireActual('../logs/testUtils');
+ const original = jest.requireActual('../logs/log');
+
+ return {
+ ...original,
+ log: createLogMock(),
+ };
+});
+
+describe('addedFunctionsRegistry', () => {
+ const originalApps = config.apps;
+ const pluginId = 'grafana-basic-app';
+ const appPluginConfig = {
+ id: pluginId,
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedFunctions: [],
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+
+ beforeEach(() => {
+ resetLogMock(log);
+ jest.mocked(isGrafanaDevMode).mockReturnValue(false);
+ config.apps = {
+ [pluginId]: appPluginConfig,
+ };
+ });
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ it('should return empty registry when no extensions registered', async () => {
+ const addedFunctionsRegistry = new AddedFunctionsRegistry();
+ const observable = addedFunctionsRegistry.asObservable();
+ const registry = await firstValueFrom(observable);
+ expect(registry).toEqual({});
+ });
+
+ it('should be possible to register function extensions in the registry', async () => {
+ const addedFunctionsRegistry = new AddedFunctionsRegistry();
+
+ addedFunctionsRegistry.register({
+ pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn(),
+ },
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn(),
+ },
+ ],
+ });
+
+ const registry = await addedFunctionsRegistry.getState();
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId,
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+ it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => {
+ const pluginId1 = 'grafana-basic-app';
+ const pluginId2 = 'grafana-basic-app2';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId1,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry1 = await reactiveRegistry.getState();
+
+ expect(registry1).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+
+ // Register extensions for the second plugin to a different placement
+ reactiveRegistry.register({
+ pluginId: pluginId2,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ {
+ pluginId: pluginId2,
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => {
+ const pluginId1 = 'grafana-basic-app';
+ const pluginId2 = 'grafana-basic-app2';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId1,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry1 = await reactiveRegistry.getState();
+
+ expect(registry1).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+
+ // Register extensions for the second plugin to a different placement
+ reactiveRegistry.register({
+ pluginId: pluginId2,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId2,
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // Register extensions to a different extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ {
+ pluginId: pluginId,
+
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // Register extensions to a different extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId,
+
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should notify subscribers when the registry changes', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ observable.subscribe(subscribeCallback);
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(2);
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: 'another-plugin',
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(3);
+
+ const registry = subscribeCallback.mock.calls[2][0];
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ {
+ pluginId: 'another-plugin',
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should give the last version of the registry for new subscribers', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should not register a function extension if it has an invalid fn function', () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ //@ts-ignore
+ fn: '...',
+ },
+ ],
+ });
+
+ expect(log.error).toHaveBeenCalled();
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+ expect(registry).toEqual({});
+ });
+
+ it('should not register a function extension if it has invalid properties (empty title)', () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: '',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(log.error).toHaveBeenCalled();
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+ expect(registry).toEqual({});
+ });
+
+ it('should not be possible to register a function on a read-only registry', async () => {
+ const pluginId = 'grafana-basic-app';
+ const registry = new AddedFunctionsRegistry();
+ const readOnlyRegistry = registry.readOnly();
+
+ expect(() => {
+ readOnlyRegistry.register({
+ pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+ }).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
+
+ const currentState = await readOnlyRegistry.getState();
+ expect(Object.keys(currentState)).toHaveLength(0);
+ });
+
+ it('should pass down fresh registrations to the read-only version of the registry', async () => {
+ const pluginId = 'grafana-basic-app';
+ const registry = new AddedFunctionsRegistry();
+ const readOnlyRegistry = registry.readOnly();
+ const subscribeCallback = jest.fn();
+ let readOnlyState;
+
+ // Should have no extensions registered in the beginning
+ readOnlyState = await readOnlyRegistry.getState();
+ expect(Object.keys(readOnlyState)).toHaveLength(0);
+
+ readOnlyRegistry.asObservable().subscribe(subscribeCallback);
+
+ // Register an extension to the original (writable) registry
+ registry.register({
+ pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // The read-only registry should have received the new extension
+ readOnlyState = await readOnlyRegistry.getState();
+ expect(Object.keys(readOnlyState)).toHaveLength(1);
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(2);
+ expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']);
+ });
+
+ it('should not register a function added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
+ // Enabling dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ // Make sure that the meta-info is empty
+ config.apps[pluginId].extensions.addedFunctions = [];
+
+ registry.register({
+ pluginId,
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(0);
+ expect(log.error).toHaveBeenCalled();
+ });
+
+ it('should register a function added by core Grafana in dev-mode even if the meta-info is missing', async () => {
+ // Enabling dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ registry.register({
+ pluginId: 'grafana',
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(1);
+ expect(log.error).not.toHaveBeenCalled();
+ });
+
+ it('should register a function added by a plugin in production mode even if the meta-info is missing', async () => {
+ // Production mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(false);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ // Make sure that the meta-info is empty
+ config.apps[pluginId].extensions.addedFunctions = [];
+
+ registry.register({
+ pluginId,
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(1);
+ expect(log.error).not.toHaveBeenCalled();
+ });
+
+ it('should register a function added by a plugin in dev-mode if the meta-info is present', async () => {
+ // Enabling dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: ['grafana/dashboard/panel/menu'],
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ // Make sure that the meta-info is empty
+ config.apps[pluginId].extensions.addedFunctions = [fnConfig];
+
+ registry.register({
+ pluginId,
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(1);
+ expect(log.error).not.toHaveBeenCalled();
+ });
+});
diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts
new file mode 100644
index 00000000000..d23fe5e78b2
--- /dev/null
+++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts
@@ -0,0 +1,87 @@
+import { isFunction } from 'lodash';
+import { ReplaySubject } from 'rxjs';
+
+import { PluginExtensionAddedFunctionConfig } from '@grafana/data';
+
+import * as errors from '../errors';
+import { isGrafanaDevMode } from '../utils';
+import { isAddedFunctionMetaInfoMissing } from '../validators';
+
+import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
+
+const logPrefix = 'Could not register function extension. Reason:';
+
+export type AddedFunctionsRegistryItem = {
+ pluginId: string;
+ title: string;
+ fn: unknown;
+ description?: string;
+};
+
+export class AddedFunctionsRegistry extends Registry {
+ constructor(
+ options: {
+ registrySubject?: ReplaySubject>;
+ initialState?: RegistryType;
+ } = {}
+ ) {
+ super(options);
+ }
+
+ mapToRegistry(
+ registry: RegistryType,
+ item: PluginExtensionConfigs
+ ): RegistryType {
+ const { pluginId, configs } = item;
+ for (const config of configs) {
+ const configLog = this.logger.child({
+ title: config.title,
+ pluginId,
+ });
+
+ if (!config.title) {
+ configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`);
+ continue;
+ }
+
+ if (!isFunction(config.fn)) {
+ configLog.error(`${logPrefix} ${errors.INVALID_EXTENSION_FUNCTION}`);
+ continue;
+ }
+
+ if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) {
+ continue;
+ }
+
+ const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets];
+ for (const extensionPointId of extensionPointIds) {
+ const pointIdLog = configLog.child({ extensionPointId });
+
+ const result = {
+ pluginId,
+ fn: config.fn,
+ description: config.description,
+ title: config.title,
+ extensionPointId,
+ };
+
+ pointIdLog.debug('Added function extension successfully registered');
+
+ if (!(extensionPointId in registry)) {
+ registry[extensionPointId] = [result];
+ } else {
+ registry[extensionPointId].push(result);
+ }
+ }
+ }
+
+ return registry;
+ }
+
+ // Returns a read-only version of the registry.
+ readOnly() {
+ return new AddedFunctionsRegistry({
+ registrySubject: this.registrySubject,
+ });
+ }
+}
diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts
index 4d5ab5c084f..d3586240276 100644
--- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts
+++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts
@@ -51,6 +51,7 @@ describe('AddedLinksRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts
index 0a7036894d1..863c89f7b40 100644
--- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts
+++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts
@@ -52,6 +52,7 @@ describe('ExposedComponentsRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts
index 6c2fd1e6a5f..91b7badc4eb 100644
--- a/public/app/features/plugins/extensions/registry/setup.ts
+++ b/public/app/features/plugins/extensions/registry/setup.ts
@@ -1,6 +1,7 @@
import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './types';
@@ -8,10 +9,12 @@ import { PluginExtensionRegistries } from './types';
export const addedComponentsRegistry = new AddedComponentsRegistry();
export const exposedComponentsRegistry = new ExposedComponentsRegistry();
export const addedLinksRegistry = new AddedLinksRegistry();
+export const addedFunctionsRegistry = new AddedFunctionsRegistry();
export const pluginExtensionRegistries: PluginExtensionRegistries = {
addedComponentsRegistry,
exposedComponentsRegistry,
addedLinksRegistry,
+ addedFunctionsRegistry,
};
// Registering core extensions
diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts
index 115e859b7d9..1927bf31b75 100644
--- a/public/app/features/plugins/extensions/registry/types.ts
+++ b/public/app/features/plugins/extensions/registry/types.ts
@@ -1,9 +1,11 @@
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
export type PluginExtensionRegistries = {
addedComponentsRegistry: AddedComponentsRegistry;
exposedComponentsRegistry: ExposedComponentsRegistry;
+ addedFunctionsRegistry: AddedFunctionsRegistry;
addedLinksRegistry: AddedLinksRegistry;
};
diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx
index 6385c022027..b2a3d3d3435 100644
--- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx
@@ -7,6 +7,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -78,6 +79,7 @@ describe('usePluginComponent()', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
// This is necessary, so we can register exposed components to the registry during the tests
// (Otherwise the registry would reject it in the imitated production mode)
exposedComponents: [exposedComponentConfig],
@@ -90,6 +92,7 @@ describe('usePluginComponent()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
@@ -122,6 +125,7 @@ describe('usePluginComponent()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx
index 75adda33f75..fcb729fdd64 100644
--- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx
@@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -60,6 +61,7 @@ describe('usePluginComponents()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(wrapWithPluginContext).mockClear();
@@ -89,6 +91,7 @@ describe('usePluginComponents()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
index 739906dd815..479e5cd4c6c 100644
--- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
@@ -1,6 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -19,6 +20,7 @@ describe('usePluginExtensions()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
});
diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx
new file mode 100644
index 00000000000..68acee76221
--- /dev/null
+++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx
@@ -0,0 +1,82 @@
+import { useMemo } from 'react';
+import { useObservable } from 'react-use';
+
+import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data';
+import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime';
+
+import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext';
+import * as errors from './errors';
+import { log } from './logs/log';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
+import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
+import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
+
+// Returns an array of component extensions for the given extension point
+export function usePluginFunctions({
+ limitPerPlugin,
+ extensionPointId,
+}: UsePluginFunctionsOptions): UsePluginFunctionsResult {
+ const registry = useAddedFunctionsRegistry();
+ const registryState = useObservable(registry.asObservable());
+ const pluginContext = usePluginContext();
+ const deps = getExtensionPointPluginDependencies(extensionPointId);
+ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
+
+ return useMemo(() => {
+ // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
+ const enableRestrictions = isGrafanaDevMode() && pluginContext;
+ const results: Array> = [];
+ const extensionsByPlugin: Record = {};
+ const pluginId = pluginContext?.meta.id ?? '';
+ const pointLog = log.child({
+ pluginId,
+ extensionPointId,
+ });
+ if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
+ pointLog.error(errors.INVALID_EXTENSION_POINT_ID);
+ }
+
+ if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
+ pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
+ return {
+ isLoading: false,
+ functions: [],
+ };
+ }
+
+ if (isLoadingAppPlugins) {
+ return {
+ isLoading: true,
+ functions: [],
+ };
+ }
+
+ for (const registryItem of registryState?.[extensionPointId] ?? []) {
+ const { pluginId } = registryItem;
+
+ // Only limit if the `limitPerPlugin` is set
+ if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
+ continue;
+ }
+
+ if (extensionsByPlugin[pluginId] === undefined) {
+ extensionsByPlugin[pluginId] = 0;
+ }
+
+ results.push({
+ id: generateExtensionId(pluginId, extensionPointId, registryItem.title),
+ type: PluginExtensionTypes.function,
+ title: registryItem.title,
+ description: registryItem.description ?? '',
+ pluginId: pluginId,
+ fn: registryItem.fn as Signature,
+ });
+ extensionsByPlugin[pluginId] += 1;
+ }
+
+ return {
+ isLoading: false,
+ functions: results,
+ };
+ }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
+}
diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
index 9186ca0ddf6..f9fb623f42a 100644
--- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
@@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -57,6 +58,7 @@ describe('usePluginLinks()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
resetLogMock(log);
@@ -85,6 +87,7 @@ describe('usePluginLinks()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx
index 32ad6215875..0e2d98f44b3 100644
--- a/public/app/features/plugins/extensions/utils.test.tsx
+++ b/public/app/features/plugins/extensions/utils.test.tsx
@@ -475,6 +475,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
@@ -553,6 +554,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
@@ -584,6 +586,7 @@ describe('Plugin Extensions / Utils', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
},
'myorg-third-app': {
@@ -623,6 +626,7 @@ describe('Plugin Extensions / Utils', () => {
],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
},
};
@@ -679,6 +683,7 @@ describe('Plugin Extensions / Utils', () => {
],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -705,6 +710,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -726,6 +732,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
},
'myorg-sixth-app': {
@@ -763,6 +770,7 @@ describe('Plugin Extensions / Utils', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
@@ -791,6 +799,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
},
'myorg-third-app': {
@@ -825,6 +834,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -850,6 +860,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -871,6 +882,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
},
};
@@ -902,6 +914,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx
index 12b146bbf5c..f083ff738ea 100644
--- a/public/app/features/plugins/extensions/validators.test.tsx
+++ b/public/app/features/plugins/extensions/validators.test.tsx
@@ -271,6 +271,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
const extensionConfig = {
@@ -387,6 +388,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
const extensionConfig = {
@@ -503,6 +505,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
const exposedComponentConfig = {
@@ -688,6 +691,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts
index b1dbd5e9af8..cbdaf81e935 100644
--- a/public/app/features/plugins/extensions/validators.ts
+++ b/public/app/features/plugins/extensions/validators.ts
@@ -5,6 +5,7 @@ import type {
PluginContextType,
PluginExtensionAddedComponentConfig,
PluginExtensionExposedComponentConfig,
+ PluginExtensionAddedFunctionConfig,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions';
import { config, isPluginExtensionLink } from '@grafana/runtime';
@@ -160,6 +161,38 @@ export const isAddedLinkMetaInfoMissing = (
return false;
};
+export const isAddedFunctionMetaInfoMissing = (
+ pluginId: string,
+ metaInfo: PluginExtensionAddedFunctionConfig,
+ log: ExtensionsLog
+) => {
+ const logPrefix = 'Could not register function extension. Reason:';
+ const app = config.apps[pluginId];
+ const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.find(({ title }) => title === metaInfo.title) : null;
+
+ if (!app) {
+ log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
+ return true;
+ }
+
+ if (!pluginJsonMetaInfo) {
+ log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`);
+ return true;
+ }
+
+ const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
+ if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
+ log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
+ return true;
+ }
+
+ if (pluginJsonMetaInfo.description !== metaInfo.description) {
+ log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
+ }
+
+ return false;
+};
+
export const isAddedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,
diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts
index 45732de7fb1..f6d9c1ac2a6 100644
--- a/public/app/features/plugins/importPanelPlugin.ts
+++ b/public/app/features/plugins/importPanelPlugin.ts
@@ -82,7 +82,6 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise {
if (!plugin.panel && plugin.angularPanelCtrl) {
plugin.panel = getAngularPanelReactWrapper(plugin);
}
-
return plugin;
})
.catch((err) => {
diff --git a/public/app/features/plugins/loader/sharedDependencies.ts b/public/app/features/plugins/loader/sharedDependencies.ts
index dbb8ed8692f..7059274c717 100644
--- a/public/app/features/plugins/loader/sharedDependencies.ts
+++ b/public/app/features/plugins/loader/sharedDependencies.ts
@@ -18,6 +18,7 @@ import { appEvents, contextSrv } from 'app/core/core';
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
import impressionSrv from 'app/core/services/impression_srv';
import TimeSeries from 'app/core/time_series2';
+import { arrayMove } from 'app/core/utils/arrayMove';
import * as flatten from 'app/core/utils/flatten';
import kbn from 'app/core/utils/kbn';
import * as ticks from 'app/core/utils/ticks';
@@ -90,7 +91,8 @@ export const sharedDependenciesMap = {
__useDefault: true,
},
...jQueryFlotDeps,
- lodash: () => import('lodash').then((module) => ({ ...module, __useDefault: true })),
+ // add move to lodash for backward compatabilty with plugins
+ lodash: () => import('lodash').then((module) => ({ ...module, move: arrayMove, __useDefault: true })),
moment: () => import('moment').then((module) => ({ ...module, __useDefault: true })),
prismjs: () => import('prismjs'),
react: () => import('react'),
diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts
index aa20ae5881f..98915c40f6d 100644
--- a/public/app/features/plugins/plugin_loader.ts
+++ b/public/app/features/plugins/plugin_loader.ts
@@ -13,7 +13,12 @@ import { DataQuery } from '@grafana/schema';
import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins';
-import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup';
+import {
+ addedComponentsRegistry,
+ addedFunctionsRegistry,
+ addedLinksRegistry,
+ exposedComponentsRegistry,
+} from './extensions/registry/setup';
import { getPluginFromCache, registerPluginInCache } from './loader/cache';
// SystemJS has to be imported before the sharedDependenciesMap
import { SystemJS } from './loader/systemjs';
@@ -153,7 +158,6 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise,
@@ -205,6 +209,10 @@ export async function importAppPlugin(meta: PluginMeta): Promise {
pluginId,
configs: plugin.addedLinkConfigs || [],
});
+ addedFunctionsRegistry.register({
+ pluginId,
+ configs: plugin.addedFunctionConfigs || [],
+ });
importedAppPlugins[pluginId] = plugin;
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 2c90bf29511..b7ea5026077 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -2781,17 +2781,30 @@
},
"labels": {
"contactGrafanaLabs": "Contact Grafana Labs",
+ "customLinks": "Custom links ",
+ "customLinksTooltip": "These links are provided by the plugin developer to offer additional, developer-specific resources and information",
"dependencies": "Dependencies",
+ "documentation": "Documentation",
"downloads": "Downloads",
"from": "From",
"installedVersion": "Installed Version",
"lastCommitDate": "Last commit date:",
"latestVersion": "Latest Version",
- "links": "Links ",
+ "license": "License",
+ "raiseAnIssue": "Raise an issue",
"reportAbuse": "Report a concern ",
+ "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.",
+ "repository": "Repository",
"signature": "Signature",
"status": "Status",
"updatedAt": "Last updated:"
+ },
+ "modal": {
+ "cancel": "Cancel",
+ "copyEmail": "Copy email address",
+ "description": "This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email us at: ",
+ "node": "Note: For general plugin issues like bugs or feature requests, please contact the plugin author using the provided links. ",
+ "title": "Report a plugin concern"
}
},
"empty-state": {
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 61b47f9f9a5..9543a62f07e 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -2781,17 +2781,30 @@
},
"labels": {
"contactGrafanaLabs": "Cőʼnŧäčŧ Ğřäƒäʼnä Ŀäþş",
+ "customLinks": "Cūşŧőm ľįʼnĸş ",
+ "customLinksTooltip": "Ŧĥęşę ľįʼnĸş äřę přővįđęđ þy ŧĥę pľūģįʼn đęvęľőpęř ŧő őƒƒęř äđđįŧįőʼnäľ, đęvęľőpęř-şpęčįƒįč řęşőūřčęş äʼnđ įʼnƒőřmäŧįőʼn",
"dependencies": "Đępęʼnđęʼnčįęş",
+ "documentation": "Đőčūmęʼnŧäŧįőʼn",
"downloads": "Đőŵʼnľőäđş",
"from": "Fřőm",
"installedVersion": "Ĩʼnşŧäľľęđ Vęřşįőʼn",
"lastCommitDate": "Ŀäşŧ čőmmįŧ đäŧę:",
"latestVersion": "Ŀäŧęşŧ Vęřşįőʼn",
- "links": "Ŀįʼnĸş ",
+ "license": "Ŀįčęʼnşę",
+ "raiseAnIssue": "Ŗäįşę äʼn įşşūę",
"reportAbuse": "Ŗępőřŧ ä čőʼnčęřʼn ",
+ "reportAbuseTooltip": "Ŗępőřŧ įşşūęş řęľäŧęđ ŧő mäľįčįőūş őř ĥäřmƒūľ pľūģįʼnş đįřęčŧľy ŧő Ğřäƒäʼnä Ŀäþş.",
+ "repository": "Ŗępőşįŧőřy",
"signature": "Ŝįģʼnäŧūřę",
"status": "Ŝŧäŧūş",
"updatedAt": "Ŀäşŧ ūpđäŧęđ:"
+ },
+ "modal": {
+ "cancel": "Cäʼnčęľ",
+ "copyEmail": "Cőpy ęmäįľ äđđřęşş",
+ "description": "Ŧĥįş ƒęäŧūřę įş ƒőř řępőřŧįʼnģ mäľįčįőūş őř ĥäřmƒūľ þęĥävįőūř ŵįŧĥįʼn pľūģįʼnş. Főř pľūģįʼn čőʼnčęřʼnş, ęmäįľ ūş äŧ: ",
+ "node": "Ńőŧę: Főř ģęʼnęřäľ pľūģįʼn įşşūęş ľįĸę þūģş őř ƒęäŧūřę řęqūęşŧş, pľęäşę čőʼnŧäčŧ ŧĥę pľūģįʼn äūŧĥőř ūşįʼnģ ŧĥę přővįđęđ ľįʼnĸş. ",
+ "title": "Ŗępőřŧ ä pľūģįʼn čőʼnčęřʼn"
}
},
"empty-state": {
diff --git a/tools/setup_grafana_alertmanager_integration_test_images.go b/tools/setup_grafana_alertmanager_integration_test_images.go
new file mode 100644
index 00000000000..3b6e1436446
--- /dev/null
+++ b/tools/setup_grafana_alertmanager_integration_test_images.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "sync"
+
+ amtests "github.com/grafana/grafana/pkg/tests/alertmanager"
+)
+
+func docker(args []string) {
+ cmd := exec.Command("docker", args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ fmt.Printf("docker pull failed: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func main() {
+ var wg sync.WaitGroup
+
+ for _, cmd := range [][]string{
+ {"pull", amtests.GetGrafanaImage()},
+ {"pull", amtests.GetLokiImage()},
+ {"pull", amtests.GetPostgresImage()},
+ {"build", "-t", "webhook-receiver", "devenv/docker/blocks/stateful_webhook"},
+ } {
+ wg.Add(1)
+
+ go func(cmd []string) {
+ defer wg.Done()
+
+ docker(cmd)
+ }(cmd)
+ }
+
+ wg.Wait()
+}