{config.featureToggles.appSidecar ? (
diff --git a/public/app/app.ts b/public/app/app.ts
index dad577603d8..556952c0775 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -85,12 +85,12 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi
import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv';
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
-import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
-import { PluginExtensionRegistries } from './features/plugins/extensions/registry/types';
+import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
+import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
@@ -127,7 +127,6 @@ if (process.env.NODE_ENV === 'development') {
export class GrafanaApp {
context!: GrafanaContextType;
- pluginExtensionsRegistries!: PluginExtensionRegistries;
async init() {
try {
@@ -217,22 +216,16 @@ export class GrafanaApp {
setDataSourceSrv(dataSourceSrv);
initWindowRuntime();
- // Initialize plugin extensions
- this.pluginExtensionsRegistries = setupPluginExtensionRegistries();
-
if (contextSrv.user.orgRole !== '') {
- // The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
- // TODO: remove the following exception once the issue mentioned above is fixed.
- const awaitedAppPluginIds = ['cloud-home-app'];
- const awaitedAppPlugins = Object.values(config.apps).filter((app) => awaitedAppPluginIds.includes(app.id));
- const appPlugins = Object.values(config.apps).filter((app) => !awaitedAppPluginIds.includes(app.id));
+ const appPluginsToAwait = getAppPluginsToAwait();
+ const appPluginsToPreload = getAppPluginsToPreload();
- preloadPlugins(appPlugins, this.pluginExtensionsRegistries);
- await preloadPlugins(awaitedAppPlugins, this.pluginExtensionsRegistries, 'frontend_awaited_plugins_preload');
+ preloadPlugins(appPluginsToPreload);
+ await preloadPlugins(appPluginsToAwait);
}
- setPluginExtensionGetter(createPluginExtensionsGetter(this.pluginExtensionsRegistries));
- setPluginExtensionsHook(createUsePluginExtensions(this.pluginExtensionsRegistries));
+ setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries));
+ setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries));
setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents);
diff --git a/public/app/features/alerting/unified/NotificationPolicies.test.tsx b/public/app/features/alerting/unified/NotificationPolicies.test.tsx
index 72001d0e596..82da6db5e59 100644
--- a/public/app/features/alerting/unified/NotificationPolicies.test.tsx
+++ b/public/app/features/alerting/unified/NotificationPolicies.test.tsx
@@ -240,7 +240,7 @@ describe.each([
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, {
alertmanager_config: {
route: {},
- receivers: [{ name: 'grafana-default-email' }],
+ receivers: [{ name: 'lotsa-emails' }],
},
template_files: {},
});
@@ -294,6 +294,7 @@ describe.each([
});
it('allows user to reload and update policies if its been changed by another user', async () => {
+ jest.retryTimes(2);
const { user } = renderNotificationPolicies();
await getRootRoute();
@@ -317,7 +318,8 @@ describe.each([
await openDefaultPolicyEditModal();
await user.click(await screen.findByRole('button', { name: /update default policy/i }));
expect(await screen.findByText(/updated notification policies/i)).toBeInTheDocument();
- });
+ // TODO: Check if test flakiness/length can be improved
+ }, 60000);
it('Should be able to delete an empty route', async () => {
const defaultConfig: AlertManagerCortexConfig = {
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx
index 89f4e72354c..8444c3169f7 100644
--- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx
@@ -1,11 +1,13 @@
-import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
+import { render, screen, waitFor, within } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
+import { setAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
+import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils';
import { AccessControlAction } from 'app/types/accessControl';
import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../../../../types/unified-alerting-dto';
-import { mockApi, setupMswServer } from '../../../mockApi';
+import { getMockConfig, setupMswServer } from '../../../mockApi';
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi';
import { Folder } from '../../../types/rule-form';
@@ -36,7 +38,7 @@ const ui = {
route: byTestId('matching-policy-route'),
routeButton: byRole('button', { name: /Expand policy route/ }),
routeMatchingInstances: byTestId('route-matching-instance'),
- loadingIndicator: byText(/Loading/),
+ loadingIndicator: byText(/Loading routing preview/i),
previewButton: byRole('button', { name: /preview routing/i }),
grafanaAlertManagerLabel: byText(/alertmanager:grafana/i),
otherAlertManagerLabel: byText(/alertmanager:other_am/i),
@@ -62,52 +64,33 @@ const grafanaAlertManagerDataSource: AlertManagerDataSource = {
hasConfigurationAPI: true,
};
+const mockConfig = getMockConfig((amConfigBuilder) =>
+ amConfigBuilder
+ .withRoute((routeBuilder) =>
+ routeBuilder
+ .withReceiver('email')
+ .addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
+ .addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
+ )
+ .addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
+ .addReceivers((b) => b.withName('slack'))
+ .addReceivers((b) => b.withName('opsgenie'))
+);
+
function mockOneAlertManager() {
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([grafanaAlertManagerDataSource]);
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
- amConfigBuilder
- .withRoute((routeBuilder) =>
- routeBuilder
- .withReceiver('email')
- .addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
- .addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
- )
- .addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
- .addReceivers((b) => b.withName('slack'))
- .addReceivers((b) => b.withName('opsgenie'))
- );
+
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
}
function mockTwoAlertManagers() {
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([
- { name: 'OTHER_AM', imgUrl: '', hasConfigurationAPI: true },
grafanaAlertManagerDataSource,
+ { name: 'OTHER_AM', imgUrl: '', hasConfigurationAPI: true },
]);
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
- amConfigBuilder
- .withRoute((routeBuilder) =>
- routeBuilder
- .withReceiver('email')
- .addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
- .addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
- )
- .addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
- .addReceivers((b) => b.withName('slack'))
- .addReceivers((b) => b.withName('opsgenie'))
- );
- mockApi(server).getAlertmanagerConfig('OTHER_AM', (amConfigBuilder) =>
- amConfigBuilder
- .withRoute((routeBuilder) =>
- routeBuilder
- .withReceiver('email')
- .addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
- .addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
- )
- .addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
- .addReceivers((b) => b.withName('slack'))
- .addReceivers((b) => b.withName('opsgenie'))
- );
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
+ setAlertmanagerConfig('OTHER_AM', mockConfig);
}
function mockHasEditPermission(enabled: boolean) {
@@ -131,14 +114,22 @@ const folder: Folder = {
title: 'title',
};
-describe('NotificationPreview', () => {
+describe.each([
+ // k8s API enabled
+ true,
+ // k8s API disabled
+ false,
+])('NotificationPreview with alertingApiServer=%p', (apiServerEnabled) => {
+ apiServerEnabled ? testWithFeatureToggles(['alertingApiServer']) : testWithFeatureToggles([]);
it('should render notification preview without alert manager label, when having only one alert manager configured to receive alerts', async () => {
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
- render(
);
+ const { user } = render(
+
+ );
- await userEvent.click(ui.previewButton.get());
+ await user.click(ui.previewButton.get());
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
@@ -160,23 +151,18 @@ describe('NotificationPreview', () => {
mockTwoAlertManagers();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
- render(
);
- await waitFor(() => {
- expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
- });
+ const { user } = render(
+
+ );
- await userEvent.click(ui.previewButton.get());
- await waitFor(() => {
- expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
- });
+ await user.click(await ui.previewButton.find());
// we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts
- await waitFor(() => {
- expect(ui.grafanaAlertManagerLabel.query()).toBeInTheDocument();
- });
- expect(ui.otherAlertManagerLabel.query()).toBeInTheDocument();
+ expect(await ui.grafanaAlertManagerLabel.find()).toBeInTheDocument();
+ expect(await ui.otherAlertManagerLabel.find()).toBeInTheDocument();
+
+ const matchingPoliciesElements = await ui.route.findAll();
- const matchingPoliciesElements = ui.route.queryAll();
expect(matchingPoliciesElements).toHaveLength(2);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
expect(matchingPoliciesElements[1]).toHaveTextContent(/tomato = red/);
@@ -187,13 +173,15 @@ describe('NotificationPreview', () => {
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(true);
- render(
);
+ const { user } = render(
+
+ );
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
- await userEvent.click(ui.previewButton.get());
- await userEvent.click(await ui.seeDetails.find());
+ await user.click(ui.previewButton.get());
+ await user.click(await ui.seeDetails.find());
expect(ui.details.title.query()).toBeInTheDocument();
//we expect seeing the default policy
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
@@ -209,13 +197,15 @@ describe('NotificationPreview', () => {
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(false);
- render(
);
+ const { user } = render(
+
+ );
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
- await userEvent.click(ui.previewButton.get());
- await userEvent.click(await ui.seeDetails.find());
+ await user.click(ui.previewButton.get());
+ await user.click(await ui.seeDetails.find());
expect(ui.details.title.query()).toBeInTheDocument();
//we expect seeing the default policy
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
@@ -234,7 +224,7 @@ describe('NotificationPreviewByAlertmanager', () => {
{ job: 'prometheus', severity: 'warning' },
];
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
+ const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
@@ -246,10 +236,9 @@ describe('NotificationPreviewByAlertmanager', () => {
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
- const user = userEvent.setup();
-
- render(
+ const { user } = render(
{
{ job: 'prometheus', severity: 'warning' },
];
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
+ const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
@@ -300,10 +289,9 @@ describe('NotificationPreviewByAlertmanager', () => {
.addReceivers((b) => b.withName('slack'))
.addReceivers((b) => b.withName('opsgenie'))
);
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
- const user = userEvent.setup();
-
- render(
+ const { user } = render(
{
{ job: 'prometheus', severity: 'warning' },
];
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
+ const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
routeBuilder
@@ -355,9 +343,9 @@ describe('NotificationPreviewByAlertmanager', () => {
.addReceivers((b) => b.withName('opsgenie'))
);
- const user = userEvent.setup();
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
- render(
+ const { user } = render(
{
it('does not match regex in middle of the word as alertmanager will anchor when queried via API', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'foobarfoo' }];
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
+ const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
@@ -402,6 +390,8 @@ describe('NotificationPreviewByAlertmanager', () => {
)
);
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
+
render(
{
it('matches regex at the start of the word', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }];
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
+ const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
@@ -426,6 +416,7 @@ describe('NotificationPreviewByAlertmanager', () => {
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, 'ba.*h'))
)
);
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
render(
{
it('handles negated regex correctly', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'thing' }];
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
+ const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
@@ -450,6 +441,7 @@ describe('NotificationPreviewByAlertmanager', () => {
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.notRegex, 'thing'))
)
);
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
render(
{
it('matches regex with flags', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }];
- mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
+ const mockConfig = getMockConfig((amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
@@ -475,6 +467,7 @@ describe('NotificationPreviewByAlertmanager', () => {
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, '(?i)BA.*h'))
)
);
+ setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
render(
- Alert instance routing preview
+
+ Alert instance routing preview
+
{isLoading && previewUninitialized && (
- Loading...
+ Loading...
)}
{previewUninitialized ? (
- When you have your folder selected and your query and labels are configured, click "Preview
- routing" to see the results here.
+
+ When you have your folder selected and your query and labels are configured, click "Preview
+ routing" to see the results here.
+
) : (
- Based on the labels added, alert instances are routed to the following notification policies. Expand each
- notification policy below to view more details.
+
+ Based on the labels added, alert instances are routed to the following notification policies. Expand
+ each notification policy below to view more details.
+
)}
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx
index f437f80df74..28d35e9e056 100644
--- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx
@@ -2,6 +2,8 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
+import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { Labels } from '../../../../../../types/unified-alerting-dto';
@@ -27,9 +29,12 @@ function NotificationPreviewByAlertManager({
);
if (error) {
+ const title = t('alerting.notification-preview.error', 'Could not load routing preview for {{alertmanager}}', {
+ alertmanager: alertManagerSource.name,
+ });
return (
-
- {error.message}
+
+ {stringifyErrorLike(error)}
);
}
@@ -46,8 +51,7 @@ function NotificationPreviewByAlertManager({
- {' '}
- Alertmanager:
+
Alertmanager:

{alertManagerSource.name}
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts
index a8df723da12..b6dcfb3e746 100644
--- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts
+++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts
@@ -1,9 +1,11 @@
import { useMemo } from 'react';
import { useAsync } from 'react-use';
+import { useContactPointsWithStatus } from 'app/features/alerting/unified/components/contact-points/useContactPoints';
+import { useNotificationPolicyRoute } from 'app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute';
+
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../../../../types/unified-alerting-dto';
-import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig';
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
@@ -11,41 +13,50 @@ import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../
import { getRoutesByIdMap, RouteWithPath } from './route';
-export const useAlertmanagerNotificationRoutingPreview = (
- alertManagerSourceName: string,
- potentialInstances: Labels[]
-) => {
- const { currentData, isLoading: configLoading, error: configError } = useAlertmanagerConfig(alertManagerSourceName);
- const config = currentData?.alertmanager_config;
+export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, potentialInstances: Labels[]) => {
+ const {
+ data: currentData,
+ isLoading: isPoliciesLoading,
+ error: policiesError,
+ } = useNotificationPolicyRoute({ alertmanager });
+
+ const {
+ contactPoints,
+ isLoading: contactPointsLoading,
+ error: contactPointsError,
+ } = useContactPointsWithStatus({
+ alertmanager,
+ fetchPolicies: false,
+ fetchStatuses: false,
+ });
const { matchInstancesToRoute } = useRouteGroupsMatcher();
- // to create the list of matching contact points we need to first get the rootRoute
- const { rootRoute, receivers } = useMemo(() => {
- if (!config) {
- return {
- receivers: [],
- rootRoute: undefined,
- };
+ const [defaultPolicy] = currentData ?? [];
+ const rootRoute = useMemo(() => {
+ if (!defaultPolicy) {
+ return;
}
-
- return {
- rootRoute: config.route ? normalizeRoute(addUniqueIdentifierToRoute(config.route)) : undefined,
- receivers: config.receivers ?? [],
- };
- }, [config]);
+ return normalizeRoute(addUniqueIdentifierToRoute(defaultPolicy));
+ }, [defaultPolicy]);
// create maps for routes to be get by id, this map also contains the path to the route
// ⚠️ don't forget to compute the inherited tree before using this map
- const routesByIdMap: Map = rootRoute
+ const routesByIdMap = rootRoute
? getRoutesByIdMap(computeInheritedTree(rootRoute))
- : new Map();
+ : new Map();
- // create map for receivers to be get by name
- const receiversByName =
- receivers.reduce((map, receiver) => {
+ // to create the list of matching contact points we need to first get the rootRoute
+ const receiversByName = useMemo(() => {
+ if (!contactPoints) {
+ return new Map();
+ }
+
+ // create map for receivers to be get by name
+ return contactPoints.reduce((map, receiver) => {
return map.set(receiver.name, receiver);
- }, new Map()) ?? new Map();
+ }, new Map());
+ }, [contactPoints]);
// match labels in the tree => map of notification policies and the alert instances (list of labels) in each one
const {
@@ -56,8 +67,9 @@ export const useAlertmanagerNotificationRoutingPreview = (
if (!rootRoute) {
return;
}
+
return await matchInstancesToRoute(rootRoute, potentialInstances, {
- unquoteMatchers: alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME,
+ unquoteMatchers: alertmanager !== GRAFANA_RULES_SOURCE_NAME,
});
}, [rootRoute, potentialInstances]);
@@ -65,7 +77,7 @@ export const useAlertmanagerNotificationRoutingPreview = (
routesByIdMap,
receiversByName,
matchingMap,
- loading: configLoading || matchingLoading,
- error: configError ?? matchingError,
+ loading: isPoliciesLoading || contactPointsLoading || matchingLoading,
+ error: policiesError ?? contactPointsError ?? matchingError,
};
};
diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts
index 81e78c10f52..5d57be50076 100644
--- a/public/app/features/alerting/unified/mockApi.ts
+++ b/public/app/features/alerting/unified/mockApi.ts
@@ -159,23 +159,11 @@ export class AlertmanagerReceiverBuilder {
}
}
-export function mockApi(server: SetupServer) {
- return {
- getAlertmanagerConfig: (amName: string, configure: (builder: AlertmanagerConfigBuilder) => void) => {
- const builder = new AlertmanagerConfigBuilder();
- configure(builder);
-
- server.use(
- http.get(`api/alertmanager/${amName}/config/api/v1/alerts`, () =>
- HttpResponse.json({
- alertmanager_config: builder.build(),
- template_files: {},
- })
- )
- );
- },
- };
-}
+export const getMockConfig = (configure: (builder: AlertmanagerConfigBuilder) => void): AlertManagerCortexConfig => {
+ const builder = new AlertmanagerConfigBuilder();
+ configure(builder);
+ return { alertmanager_config: builder.build(), template_files: {} };
+};
export function mockAlertRuleApi(server: SetupServer) {
return {
diff --git a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts
index 613ad3e114c..9babb1332bf 100644
--- a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts
+++ b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts
@@ -14,9 +14,8 @@ const normalizeMatchers = (route: Route) => {
const routeMatchers: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Matcher[] = [];
if (route.object_matchers) {
- // todo foreach
- route.object_matchers.map(([label, type, value]) => {
- return { label, type, value };
+ route.object_matchers.forEach(([label, type, value]) => {
+ routeMatchers.push({ label, type, value });
});
}
diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
index 84588563a0c..461ef3810ce 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
@@ -7,41 +7,43 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } f
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { PROVENANCE_NONE, K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants';
-const config = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
+const getReceiversList = () => {
+ const config = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
-// Turn our mock alertmanager config into the format that we expect to be returned by the k8s API
-const mappedReceivers =
- config.alertmanager_config?.receivers?.map((contactPoint) => {
- const provenance =
- contactPoint.grafana_managed_receiver_configs?.find((integration) => {
- return integration.provenance;
- })?.provenance || PROVENANCE_NONE;
- return {
- metadata: {
- // This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses
- uid: camelCase(contactPoint.name),
- annotations: {
- [K8sAnnotations.Provenance]: provenance,
- [K8sAnnotations.AccessAdmin]: 'true',
- [K8sAnnotations.AccessDelete]: 'true',
- [K8sAnnotations.AccessWrite]: 'true',
+ // Turn our mock alertmanager config into the format that we expect to be returned by the k8s API
+ const mappedReceivers =
+ config.alertmanager_config?.receivers?.map((contactPoint) => {
+ const provenance =
+ contactPoint.grafana_managed_receiver_configs?.find((integration) => {
+ return integration.provenance;
+ })?.provenance || PROVENANCE_NONE;
+ return {
+ metadata: {
+ // This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses
+ uid: camelCase(contactPoint.name),
+ annotations: {
+ [K8sAnnotations.Provenance]: provenance,
+ [K8sAnnotations.AccessAdmin]: 'true',
+ [K8sAnnotations.AccessDelete]: 'true',
+ [K8sAnnotations.AccessWrite]: 'true',
+ },
},
- },
- spec: {
- title: contactPoint.name,
- integrations: contactPoint.grafana_managed_receiver_configs || [],
- },
- };
- }) || [];
+ spec: {
+ title: contactPoint.name,
+ integrations: contactPoint.grafana_managed_receiver_configs || [],
+ },
+ };
+ }) || [];
-const parsedReceivers = getK8sResponse(
- 'ReceiverList',
- mappedReceivers
-);
+ return getK8sResponse(
+ 'ReceiverList',
+ mappedReceivers
+ );
+};
const listNamespacedReceiverHandler = () =>
http.get<{ namespace: string }>(`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/receivers`, () => {
- return HttpResponse.json(parsedReceivers);
+ return HttpResponse.json(getReceiversList());
});
const createNamespacedReceiverHandler = () =>
@@ -58,6 +60,7 @@ const deleteNamespacedReceiverHandler = () =>
`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/receivers/:name`,
({ params }) => {
const { name } = params;
+ const parsedReceivers = getReceiversList();
const matchedReceiver = parsedReceivers.items.find((receiver) => receiver.metadata.uid === name);
if (matchedReceiver) {
return HttpResponse.json(parsedReceivers);
diff --git a/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts b/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts
index 2e08a15c640..cf2ca614f15 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts
@@ -3,15 +3,19 @@ import { HttpResponse, http } from 'msw';
import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
-const alertmanagerConfig = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
-const defaultReceiversResponse = alertmanagerConfig.alertmanager_config.receivers;
-const defaultTimeIntervalsResponse = alertmanagerConfig.alertmanager_config.time_intervals;
+const getNotificationReceiversHandler = () =>
+ http.get('/api/v1/notifications/receivers', () => {
+ const receivers = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME).alertmanager_config.receivers || [];
-const getNotificationReceiversHandler = (response = defaultReceiversResponse) =>
- http.get('/api/v1/notifications/receivers', () => HttpResponse.json(response));
+ return HttpResponse.json(receivers);
+ });
-const getTimeIntervalsHandler = (response = defaultTimeIntervalsResponse) =>
- http.get('/api/v1/notifications/time-intervals', () => HttpResponse.json(response));
+const getTimeIntervalsHandler = () =>
+ http.get('/api/v1/notifications/time-intervals', () => {
+ const intervals = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME).alertmanager_config.time_intervals;
+
+ return HttpResponse.json(intervals);
+ });
const handlers = [getNotificationReceiversHandler(), getTimeIntervalsHandler()];
diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx
index 805a780be3c..d9b3bd9d77d 100644
--- a/public/app/features/plugins/components/AppRootPage.test.tsx
+++ b/public/app/features/plugins/components/AppRootPage.test.tsx
@@ -11,7 +11,9 @@ import { contextSrv } from 'app/core/services/context_srv';
import { Echo } from 'app/core/services/echo/Echo';
import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext';
-import { setupPluginExtensionRegistries } from '../extensions/registry/setup';
+import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
+import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
+import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
@@ -86,7 +88,11 @@ function renderUnderRouter(page = '') {
appPluginNavItem.parentItem = appsSection;
- const registries = setupPluginExtensionRegistries();
+ const registries = {
+ addedComponentsRegistry: new AddedComponentsRegistry(),
+ exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ addedLinksRegistry: new AddedLinksRegistry(),
+ };
const pagePath = page ? `/${page}` : '';
const route = {
path: `/a/:pluginId/*`,
diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts
index 05fe0f21b7a..6c2fd1e6a5f 100644
--- a/public/app/features/plugins/extensions/registry/setup.ts
+++ b/public/app/features/plugins/extensions/registry/setup.ts
@@ -5,17 +5,17 @@ import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './types';
-export function setupPluginExtensionRegistries(): PluginExtensionRegistries {
- const pluginExtensionsRegistries = {
- addedComponentsRegistry: new AddedComponentsRegistry(),
- exposedComponentsRegistry: new ExposedComponentsRegistry(),
- addedLinksRegistry: new AddedLinksRegistry(),
- };
+export const addedComponentsRegistry = new AddedComponentsRegistry();
+export const exposedComponentsRegistry = new ExposedComponentsRegistry();
+export const addedLinksRegistry = new AddedLinksRegistry();
+export const pluginExtensionRegistries: PluginExtensionRegistries = {
+ addedComponentsRegistry,
+ exposedComponentsRegistry,
+ addedLinksRegistry,
+};
- pluginExtensionsRegistries.addedLinksRegistry.register({
- pluginId: 'grafana',
- configs: getCoreExtensionConfigurations(),
- });
-
- return pluginExtensionsRegistries;
-}
+// Registering core extensions
+addedLinksRegistry.register({
+ pluginId: 'grafana',
+ configs: getCoreExtensionConfigurations(),
+});
diff --git a/public/app/features/plugins/extensions/useLoadAppPlugins.tsx b/public/app/features/plugins/extensions/useLoadAppPlugins.tsx
new file mode 100644
index 00000000000..ce013713906
--- /dev/null
+++ b/public/app/features/plugins/extensions/useLoadAppPlugins.tsx
@@ -0,0 +1,19 @@
+import { useAsync } from 'react-use';
+
+import { preloadPlugins } from '../pluginPreloader';
+
+import { getAppPluginConfigs } from './utils';
+
+export function useLoadAppPlugins(pluginIds: string[] = []): { isLoading: boolean } {
+ const { loading: isLoading } = useAsync(async () => {
+ const appConfigs = getAppPluginConfigs(pluginIds);
+
+ if (!appConfigs.length) {
+ return;
+ }
+
+ await preloadPlugins(appConfigs);
+ });
+
+ return { isLoading };
+}
diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx
index 007414ab4dc..9491700e217 100644
--- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx
@@ -7,11 +7,15 @@ import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
-import { setupPluginExtensionRegistries } from './registry/setup';
+import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
+import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginComponent } from './usePluginComponent';
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
+jest.mock('./useLoadAppPlugins');
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
id: 'my-app-plugin',
@@ -83,7 +87,12 @@ describe('usePluginComponent()', () => {
};
beforeEach(() => {
- registries = setupPluginExtensionRegistries();
+ registries = {
+ addedComponentsRegistry: new AddedComponentsRegistry(),
+ exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ addedLinksRegistry: new AddedLinksRegistry(),
+ };
+ jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
resetLogMock(log);
diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx
index ba057721f26..5e079beea93 100644
--- a/public/app/features/plugins/extensions/usePluginComponent.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponent.tsx
@@ -7,7 +7,8 @@ import { UsePluginComponentResult } from '@grafana/runtime';
import { useExposedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
-import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
+import { getExposedComponentPluginDependencies, isGrafanaDevMode, wrapWithPluginContext } from './utils';
import { isExposedComponentDependencyMissing } from './validators';
// Returns a component exposed by a plugin.
@@ -16,11 +17,19 @@ export function usePluginComponent(id: string): UsePl
const registry = useExposedComponentsRegistry();
const registryState = useObservable(registry.asObservable());
const pluginContext = usePluginContext();
+ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id));
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;
+ if (isLoadingAppPlugins) {
+ return {
+ isLoading: true,
+ component: null,
+ };
+ }
+
if (!registryState?.[id]) {
return {
isLoading: false,
@@ -47,5 +56,5 @@ export function usePluginComponent(id: string): UsePl
isLoading: false,
component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog),
};
- }, [id, pluginContext, registryState]);
+ }, [id, pluginContext, registryState, isLoadingAppPlugins]);
}
diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx
index e873e6e81a7..5528525bf47 100644
--- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx
@@ -6,11 +6,15 @@ import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
-import { setupPluginExtensionRegistries } from './registry/setup';
+import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
+import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginComponents } from './usePluginComponents';
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
+jest.mock('./useLoadAppPlugins');
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
id: 'my-app-plugin',
@@ -50,8 +54,14 @@ describe('usePluginComponents()', () => {
beforeEach(() => {
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
+ jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
+
resetLogMock(log);
- registries = setupPluginExtensionRegistries();
+ registries = {
+ addedComponentsRegistry: new AddedComponentsRegistry(),
+ exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ addedLinksRegistry: new AddedLinksRegistry(),
+ };
jest.mocked(wrapWithPluginContext).mockClear();
diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx
index 3147a8441d9..e583a69141d 100644
--- a/public/app/features/plugins/extensions/usePluginComponents.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponents.tsx
@@ -10,7 +10,8 @@ import {
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
-import { isGrafanaDevMode } from './utils';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
+import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
// Returns an array of component extensions for the given extension point
@@ -21,6 +22,7 @@ export function usePluginComponents({
const registry = useAddedComponentsRegistry();
const registryState = useObservable(registry.asObservable());
const pluginContext = usePluginContext();
+ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
@@ -45,6 +47,13 @@ export function usePluginComponents({
};
}
+ if (isLoadingAppPlugins) {
+ return {
+ isLoading: true,
+ components: [],
+ };
+ }
+
for (const registryItem of registryState?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
@@ -65,5 +74,5 @@ export function usePluginComponents({
isLoading: false,
components,
};
- }, [extensionPointId, limitPerPlugin, pluginContext, registryState]);
+ }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
}
diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
index 71254272588..203b55391a0 100644
--- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
@@ -5,8 +5,11 @@ import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
import { createUsePluginExtensions } from './usePluginExtensions';
+jest.mock('./useLoadAppPlugins');
+
describe('usePluginExtensions()', () => {
let registries: PluginExtensionRegistries;
const pluginId = 'myorg-extensions-app';
@@ -18,6 +21,7 @@ describe('usePluginExtensions()', () => {
addedLinksRegistry: new AddedLinksRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
};
+ jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
});
it('should return an empty array if there are no extensions registered for the extension point', () => {
diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx
index 5a7050062e2..2dfc57e289e 100644
--- a/public/app/features/plugins/extensions/usePluginExtensions.tsx
+++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx
@@ -8,7 +8,8 @@ import * as errors from './errors';
import { getPluginExtensions } from './getPluginExtensions';
import { log } from './logs/log';
import { PluginExtensionRegistries } from './registry/types';
-import { isGrafanaDevMode } from './utils';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
+import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
@@ -20,8 +21,9 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
const { extensionPointId, context, limitPerPlugin } = options;
+ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
- const { extensions } = useMemo(() => {
+ 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 !== null;
const pluginId = pluginContext?.meta.id ?? '';
@@ -50,19 +52,35 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
};
}
- return getPluginExtensions({
+ if (isLoadingAppPlugins) {
+ return {
+ isLoading: true,
+ extensions: [],
+ };
+ }
+
+ const { extensions } = getPluginExtensions({
extensionPointId,
context,
limitPerPlugin,
addedComponentsRegistry,
addedLinksRegistry,
});
+
+ return { extensions, isLoading: false };
+
// Doing the deps like this instead of just `option` because users probably aren't going to memoize the
// options object so we are checking it's simple value attributes.
// The context though still has to be memoized though and not mutated.
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood
- }, [addedLinksRegistry, addedComponentsRegistry, extensionPointId, context, limitPerPlugin, pluginContext]);
-
- return { extensions, isLoading: false };
+ }, [
+ addedLinksRegistry,
+ addedComponentsRegistry,
+ extensionPointId,
+ context,
+ limitPerPlugin,
+ pluginContext,
+ isLoadingAppPlugins,
+ ]);
};
}
diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
index c9d6ca2af39..dc9729560d9 100644
--- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
@@ -6,11 +6,15 @@ import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
-import { setupPluginExtensionRegistries } from './registry/setup';
+import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
+import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginLinks } from './usePluginLinks';
import { isGrafanaDevMode } from './utils';
+jest.mock('./useLoadAppPlugins');
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
id: 'my-app-plugin',
@@ -48,8 +52,13 @@ describe('usePluginLinks()', () => {
const extensionPointId = `${pluginId}/extension-point/v1`;
beforeEach(() => {
+ jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
- registries = setupPluginExtensionRegistries();
+ registries = {
+ addedComponentsRegistry: new AddedComponentsRegistry(),
+ exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ addedLinksRegistry: new AddedLinksRegistry(),
+ };
resetLogMock(log);
pluginMeta = {
diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx
index 1a7205e347a..58efcc4e3e6 100644
--- a/public/app/features/plugins/extensions/usePluginLinks.tsx
+++ b/public/app/features/plugins/extensions/usePluginLinks.tsx
@@ -11,8 +11,10 @@ import {
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
import {
generateExtensionId,
+ getExtensionPointPluginDependencies,
getLinkExtensionOnClick,
getLinkExtensionOverrides,
getLinkExtensionPathWithTracking,
@@ -30,6 +32,7 @@ export function usePluginLinks({
const registry = useAddedLinksRegistry();
const pluginContext = usePluginContext();
const registryState = useObservable(registry.asObservable());
+ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
@@ -56,6 +59,13 @@ export function usePluginLinks({
};
}
+ if (isLoadingAppPlugins) {
+ return {
+ isLoading: true,
+ links: [],
+ };
+ }
+
if (!registryState || !registryState[extensionPointId]) {
return {
isLoading: false,
@@ -117,5 +127,5 @@ export function usePluginLinks({
isLoading: false,
links: extensions,
};
- }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]);
+ }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]);
}
diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx
index b78afe339ba..606661af2a3 100644
--- a/public/app/features/plugins/extensions/utils.test.tsx
+++ b/public/app/features/plugins/extensions/utils.test.tsx
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs';
-import { dateTime, usePluginContext } from '@grafana/data';
+import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data';
+import { config } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events';
@@ -12,6 +13,10 @@ import {
getReadOnlyProxy,
createOpenModalFunction,
wrapWithPluginContext,
+ getExtensionPointPluginDependencies,
+ getExposedComponentPluginDependencies,
+ getAppPluginConfigs,
+ getAppPluginIdFromExposedComponentId,
} from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
@@ -447,4 +452,431 @@ describe('Plugin Extensions / Utils', () => {
expect(screen.getByText('Version: 1.0.0')).toBeVisible();
});
});
+
+ describe('getAppPluginConfigs()', () => {
+ const originalApps = config.apps;
+ const genereicAppPluginConfig = {
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ test('should return the app plugin configs based on the provided plugin ids', () => {
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ },
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ },
+ };
+
+ expect(getAppPluginConfigs(['myorg-first-app', 'myorg-third-app'])).toEqual([
+ config.apps['myorg-first-app'],
+ config.apps['myorg-third-app'],
+ ]);
+ });
+
+ test('should simply ignore the app plugin ids that do not belong to a config', () => {
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ },
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ },
+ };
+
+ expect(getAppPluginConfigs(['myorg-first-app', 'unknown-app-id'])).toEqual([config.apps['myorg-first-app']]);
+ });
+ });
+
+ describe('getAppPluginIdFromExposedComponentId()', () => {
+ test('should return the app plugin id from an extension point id', () => {
+ expect(getAppPluginIdFromExposedComponentId('myorg-extensions-app/component/v1')).toBe('myorg-extensions-app');
+ });
+ });
+
+ describe('getExtensionPointPluginDependencies()', () => {
+ const originalApps = config.apps;
+ const genereicAppPluginConfig = {
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ test('should return the app plugin ids that register extensions to a link extension point', () => {
+ const extensionPointId = 'myorg-first-app/link/v1';
+
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ // This plugin is registering a link extension to the extension point
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ extensions: {
+ addedLinks: [
+ {
+ targets: [extensionPointId],
+ title: 'Link title',
+ },
+ ],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ },
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ },
+ };
+
+ const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
+
+ expect(appPluginIds).toEqual(['myorg-second-app']);
+ });
+
+ test('should return the app plugin ids that register extensions to a component extension point', () => {
+ const extensionPointId = 'myorg-first-app/component/v1';
+
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ },
+ // This plugin is registering a component extension to the extension point
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [
+ {
+ targets: [extensionPointId],
+ title: 'Component title',
+ },
+ ],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ },
+ };
+
+ const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
+
+ expect(appPluginIds).toEqual(['myorg-third-app']);
+ });
+
+ test('should return an empty array if there are no apps that that extend the extension point', () => {
+ const extensionPointId = 'myorg-first-app/component/v1';
+
+ // None of the apps are extending the extension point
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ },
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ },
+ };
+
+ const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
+
+ expect(appPluginIds).toEqual([]);
+ });
+
+ test('should also return (recursively) the app plugin ids that the apps which extend the extension-point depend on', () => {
+ const extensionPointId = 'myorg-first-app/component/v1';
+
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ // This plugin is registering a component extension to the extension point.
+ // It is also depending on the 'myorg-fourth-app' plugin.
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [
+ {
+ targets: [extensionPointId],
+ title: 'Component title',
+ },
+ ],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ dependencies: {
+ ...genereicAppPluginConfig.dependencies,
+ extensions: {
+ exposedComponents: ['myorg-fourth-app/component/v1'],
+ },
+ },
+ },
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ },
+ // This plugin exposes a component, but is also depending on the 'myorg-fifth-app'.
+ 'myorg-fourth-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-fourth-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [
+ {
+ id: 'myorg-fourth-app/component/v1',
+ title: 'Exposed component',
+ },
+ ],
+ extensionPoints: [],
+ },
+ dependencies: {
+ ...genereicAppPluginConfig.dependencies,
+ extensions: {
+ exposedComponents: ['myorg-fifth-app/component/v1'],
+ },
+ },
+ },
+ 'myorg-fifth-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-fifth-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [
+ {
+ id: 'myorg-fifth-app/component/v1',
+ title: 'Exposed component',
+ },
+ ],
+ extensionPoints: [],
+ },
+ },
+ 'myorg-sixth-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-sixth-app',
+ },
+ };
+
+ const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
+
+ expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']);
+ });
+ });
+
+ describe('getExposedComponentPluginDependencies()', () => {
+ const originalApps = config.apps;
+ const genereicAppPluginConfig = {
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ test('should only return the app plugin id that exposes the component, if that component does not depend on anything', () => {
+ const exposedComponentId = 'myorg-second-app/component/v1';
+
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [
+ {
+ id: exposedComponentId,
+ title: 'Component title',
+ },
+ ],
+ extensionPoints: [],
+ },
+ },
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ },
+ };
+
+ const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId);
+
+ expect(appPluginIds).toEqual(['myorg-second-app']);
+ });
+
+ test('should also return the list of app plugin ids that the plugin - which exposes the component - is depending on', () => {
+ const exposedComponentId = 'myorg-second-app/component/v1';
+
+ config.apps = {
+ 'myorg-first-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-first-app',
+ },
+ 'myorg-second-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-second-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [
+ {
+ id: exposedComponentId,
+ title: 'Component title',
+ },
+ ],
+ extensionPoints: [],
+ },
+ dependencies: {
+ ...genereicAppPluginConfig.dependencies,
+ extensions: {
+ exposedComponents: ['myorg-fourth-app/component/v1'],
+ },
+ },
+ },
+ 'myorg-third-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-third-app',
+ },
+ 'myorg-fourth-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-fourth-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [
+ {
+ id: 'myorg-fourth-app/component/v1',
+ title: 'Component title',
+ },
+ ],
+ extensionPoints: [],
+ },
+ dependencies: {
+ ...genereicAppPluginConfig.dependencies,
+ extensions: {
+ exposedComponents: ['myorg-fifth-app/component/v1'],
+ },
+ },
+ },
+ 'myorg-fifth-app': {
+ ...genereicAppPluginConfig,
+ id: 'myorg-fifth-app',
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [
+ {
+ id: 'myorg-fifth-app/component/v1',
+ title: 'Component title',
+ },
+ ],
+ extensionPoints: [],
+ },
+ },
+ };
+
+ const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId);
+
+ expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']);
+ });
+ });
});
diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx
index b4819a3f1b8..bb339ffcf38 100644
--- a/public/app/features/plugins/extensions/utils.tsx
+++ b/public/app/features/plugins/extensions/utils.tsx
@@ -16,8 +16,9 @@ import {
PanelMenuItem,
PluginExtensionAddedLinkConfig,
urlUtil,
+ PluginExtensionPoints,
} from '@grafana/data';
-import { reportInteraction, config } from '@grafana/runtime';
+import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
@@ -421,3 +422,75 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
// Comes from the `app_mode` setting in the Grafana config (defaults to "development")
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
+
+export const getAppPluginConfigs = (pluginIds: string[] = []) =>
+ Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
+
+export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => {
+ return exposedComponentId.split('/')[0];
+};
+
+// Returns a list of app plugin ids that are registering extensions to this extension point.
+// (These plugins are necessary to be loaded to use the extension point.)
+// (The function also returns the plugin ids that the plugins - that extend the extension point - depend on.)
+export const getExtensionPointPluginDependencies = (extensionPointId: string): string[] => {
+ return Object.values(config.apps)
+ .filter(
+ (app) =>
+ app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) ||
+ app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId))
+ )
+ .map((app) => app.id)
+ .reduce((acc: string[], id: string) => {
+ return [...acc, id, ...getAppPluginDependencies(id)];
+ }, []);
+};
+
+// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
+// (It is first the plugin that exposes the component, and then the ones that it depends on.)
+export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {
+ const pluginId = getAppPluginIdFromExposedComponentId(exposedComponentId);
+
+ return [pluginId].reduce((acc: string[], pluginId: string) => {
+ return [...acc, pluginId, ...getAppPluginDependencies(pluginId)];
+ }, []);
+};
+
+// Returns a list of app plugin ids that are necessary to be loaded, based on the `dependencies.extensions`
+// metadata field. (For example the plugins that expose components that the app depends on.)
+// Heads up! This is a recursive function.
+export const getAppPluginDependencies = (pluginId: string): string[] => {
+ if (!config.apps[pluginId]) {
+ return [];
+ }
+
+ const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map(
+ getAppPluginIdFromExposedComponentId
+ );
+
+ return pluginIdDependencies.reduce((acc, pluginId) => {
+ return [...acc, ...getAppPluginDependencies(pluginId)];
+ }, pluginIdDependencies);
+};
+
+// Returns a list of app plugins that has to be loaded before core Grafana could finish the initialization.
+export const getAppPluginsToAwait = () => {
+ const pluginIds = [
+ // The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
+ 'cloud-home-app',
+ ];
+
+ return Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
+};
+
+// Returns a list of app plugins that has to be preloaded in parallel with the core Grafana initialization.
+export const getAppPluginsToPreload = () => {
+ // The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
+ const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(PluginExtensionPoints.DashboardPanelMenu);
+ const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.id);
+ const isNotAwaited = (app: AppPluginConfig) => !awaitedPluginIds.includes(app.id);
+
+ return Object.values(config.apps).filter((app) => {
+ return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
+ });
+};
diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts
index b4d7489f80f..8b7a85759be 100644
--- a/public/app/features/plugins/pluginPreloader.ts
+++ b/public/app/features/plugins/pluginPreloader.ts
@@ -1,11 +1,9 @@
import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
import type { AppPluginConfig } from '@grafana/runtime';
-import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
-import { PluginExtensionRegistries } from './extensions/registry/types';
-import { importPluginModule } from './plugin_loader';
+import { importAppPlugin } from './plugin_loader';
export type PluginPreloadResult = {
pluginId: string;
@@ -15,67 +13,28 @@ export type PluginPreloadResult = {
addedLinkConfigs?: PluginExtensionAddedLinkConfig[];
};
-export async function preloadPlugins(
- apps: AppPluginConfig[] = [],
- registries: PluginExtensionRegistries,
- eventName = 'frontend_plugins_preload'
-) {
- startMeasure(eventName);
- const promises = apps.filter((config) => config.preload).map((config) => preload(config));
- const preloadedPlugins = await Promise.all(promises);
+const preloadedAppPlugins = new Set();
+const isNotYetPreloaded = ({ id }: AppPluginConfig) => !preloadedAppPlugins.has(id);
+const markAsPreloaded = (apps: AppPluginConfig[]) => apps.forEach(({ id }) => preloadedAppPlugins.add(id));
- for (const preloadedPlugin of preloadedPlugins) {
- if (preloadedPlugin.error) {
- console.error(`[Plugins] Skip loading extensions for "${preloadedPlugin.pluginId}" due to an error.`);
- continue;
- }
+export async function preloadPlugins(apps: AppPluginConfig[] = []) {
+ const appPluginsToPreload = apps.filter(isNotYetPreloaded);
- registries.exposedComponentsRegistry.register({
- pluginId: preloadedPlugin.pluginId,
- configs: preloadedPlugin.exposedComponentConfigs,
- });
- registries.addedComponentsRegistry.register({
- pluginId: preloadedPlugin.pluginId,
- configs: preloadedPlugin.addedComponentConfigs || [],
- });
- registries.addedLinksRegistry.register({
- pluginId: preloadedPlugin.pluginId,
- configs: preloadedPlugin.addedLinkConfigs || [],
- });
+ if (appPluginsToPreload.length === 0) {
+ return;
}
- stopMeasure(eventName);
+ markAsPreloaded(apps);
+
+ await Promise.all(appPluginsToPreload.map(preload));
}
-async function preload(config: AppPluginConfig): Promise {
- const { path, version, id: pluginId, loadingStrategy } = config;
+async function preload(config: AppPluginConfig) {
try {
- startMeasure(`frontend_plugin_preload_${pluginId}`);
- const { plugin } = await importPluginModule({
- path,
- version,
- isAngular: config.angular.detected,
- pluginId,
- loadingStrategy,
- moduleHash: config.moduleHash,
- });
- const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin;
+ const meta = await getPluginSettings(config.id);
- // Fetching meta-information for the preloaded app plugin and caching it for later.
- // (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.)
- getPluginSettings(pluginId);
-
- return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs };
+ await importAppPlugin(meta);
} catch (error) {
- console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);
- return {
- pluginId,
- error,
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- addedLinkConfigs: [],
- };
- } finally {
- stopMeasure(`frontend_plugin_preload_${pluginId}`);
+ console.error(`[Plugins] Failed to preload plugin: ${config.path} (version: ${config.version})`, error);
}
}
diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts
index cf65adc4811..aa20ae5881f 100644
--- a/public/app/features/plugins/plugin_loader.ts
+++ b/public/app/features/plugins/plugin_loader.ts
@@ -13,6 +13,7 @@ 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 { getPluginFromCache, registerPluginInCache } from './loader/cache';
// SystemJS has to be imported before the sharedDependenciesMap
import { SystemJS } from './loader/systemjs';
@@ -69,6 +70,15 @@ systemJSPrototype.resolve = decorateSystemJSResolve.bind(systemJSPrototype, syst
// Any css files loaded via SystemJS have their styles applied onload.
systemJSPrototype.onload = decorateSystemJsOnload;
+type PluginImportInfo = {
+ path: string;
+ pluginId: string;
+ loadingStrategy: PluginLoadingStrategy;
+ version?: string;
+ isAngular?: boolean;
+ moduleHash?: string;
+};
+
export async function importPluginModule({
path,
pluginId,
@@ -76,14 +86,7 @@ export async function importPluginModule({
version,
isAngular,
moduleHash,
-}: {
- path: string;
- pluginId: string;
- loadingStrategy: PluginLoadingStrategy;
- version?: string;
- isAngular?: boolean;
- moduleHash?: string;
-}): Promise {
+}: PluginImportInfo): Promise {
if (version) {
registerPluginInCache({ path, version, loadingStrategy });
}
@@ -166,21 +169,44 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise {
- const isAngular = meta.angular?.detected ?? meta.angularDetected;
- const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
- return importPluginModule({
+// Only successfully loaded plugins are cached
+const importedAppPlugins: Record = {};
+
+export async function importAppPlugin(meta: PluginMeta): Promise {
+ const pluginId = meta.id;
+
+ if (importedAppPlugins[pluginId]) {
+ return importedAppPlugins[pluginId];
+ }
+
+ const pluginExports = await importPluginModule({
path: meta.module,
version: meta.info?.version,
- isAngular,
- loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id,
+ isAngular: meta.angular?.detected ?? meta.angularDetected,
+ loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch,
moduleHash: meta.moduleHash,
- }).then((pluginExports) => {
- const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin();
- plugin.init(meta);
- plugin.meta = meta;
- plugin.setComponentsFromLegacyExports(pluginExports);
- return plugin;
});
+
+ const { plugin = new AppPlugin() } = pluginExports;
+ plugin.init(meta);
+ plugin.meta = meta;
+ plugin.setComponentsFromLegacyExports(pluginExports);
+
+ exposedComponentsRegistry.register({
+ pluginId,
+ configs: plugin.exposedComponentConfigs || [],
+ });
+ addedComponentsRegistry.register({
+ pluginId,
+ configs: plugin.addedComponentConfigs || [],
+ });
+ addedLinksRegistry.register({
+ pluginId,
+ configs: plugin.addedLinkConfigs || [],
+ });
+
+ importedAppPlugins[pluginId] = plugin;
+
+ return plugin;
}
diff --git a/public/app/features/plugins/tests/plugin_loader.test.ts b/public/app/features/plugins/tests/plugin_loader.test.ts
index e81e833966a..d41caa1dbe1 100644
--- a/public/app/features/plugins/tests/plugin_loader.test.ts
+++ b/public/app/features/plugins/tests/plugin_loader.test.ts
@@ -12,27 +12,25 @@ jest.mock('app/core/core', () => {
import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data';
// Loaded after the `unmock` above
+import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from '../extensions/registry/setup';
import { SystemJS } from '../loader/systemjs';
import { importAppPlugin } from '../plugin_loader';
-class MyCustomApp extends AppPlugin {
- initWasCalled = false;
- calledTwice = false;
-
- init(meta: AppPluginMeta) {
- this.initWasCalled = true;
- this.calledTwice = this.meta === meta;
- }
-}
+jest.mock('../extensions/registry/setup');
describe('Load App', () => {
- const app = new MyCustomApp();
+ const app = new AppPlugin();
const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js';
// Hook resolver for tests
const originalResolve = SystemJS.constructor.prototype.resolve;
SystemJS.constructor.prototype.resolve = (x: unknown) => x;
beforeAll(() => {
+ app.init = jest.fn();
+ addedComponentsRegistry.register = jest.fn();
+ addedLinksRegistry.register = jest.fn();
+ exposedComponentsRegistry.register = jest.fn();
+
SystemJS.set(modulePath, { plugin: app });
});
@@ -55,14 +53,17 @@ describe('Load App', () => {
const m = await SystemJS.import(modulePath);
expect(m.plugin).toBe(app);
- const loaded = await importAppPlugin(meta);
- expect(loaded).toBe(app);
+ // Importing the app should initialise the meta
+ const importedApp = await importAppPlugin(meta);
+ expect(importedApp).toBe(app);
expect(app.meta).toBe(meta);
- expect(app.initWasCalled).toBeTruthy();
- expect(app.calledTwice).toBeFalsy();
- const again = await importAppPlugin(meta);
- expect(again).toBe(app);
- expect(app.calledTwice).toBeTruthy();
+ // Importing the same app again doesn't initialise it twice
+ const importedAppAgain = await importAppPlugin(meta);
+ expect(importedAppAgain).toBe(app);
+ expect(app.init).toHaveBeenCalledTimes(1);
+ expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1);
+ expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1);
+ expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1);
});
});
diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json
index f4d7eb31706..ae56080d8ca 100644
--- a/public/locales/de-DE/grafana.json
+++ b/public/locales/de-DE/grafana.json
@@ -245,6 +245,7 @@
"edit": "",
"export": "",
"export-all": "",
+ "loading": "",
"view": ""
},
"contact-points": {
@@ -329,6 +330,14 @@
"save": "",
"saving": ""
},
+ "notification-preview": {
+ "alertmanager": "",
+ "error": "",
+ "initialized": "",
+ "preview-routing": "",
+ "title": "",
+ "uninitialized": ""
+ },
"policies": {
"default-policy": {
"description": "",
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index ff385eca064..3aca6723ded 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -245,6 +245,7 @@
"edit": "Edit",
"export": "Export",
"export-all": "Export all",
+ "loading": "Loading...",
"view": "View"
},
"contact-points": {
@@ -329,6 +330,14 @@
"save": "Save mute timing",
"saving": "Saving mute timing"
},
+ "notification-preview": {
+ "alertmanager": "Alertmanager:",
+ "error": "Could not load routing preview for {{alertmanager}}",
+ "initialized": "Based on the labels added, alert instances are routed to the following notification policies. Expand each notification policy below to view more details.",
+ "preview-routing": "Preview routing",
+ "title": "Alert instance routing preview",
+ "uninitialized": "When you have your folder selected and your query and labels are configured, click \"Preview routing\" to see the results here."
+ },
"policies": {
"default-policy": {
"description": "All alert instances will be handled by the default policy if no other matching policies are found.",
diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json
index 85d1c76449c..fedba1f1abf 100644
--- a/public/locales/es-ES/grafana.json
+++ b/public/locales/es-ES/grafana.json
@@ -245,6 +245,7 @@
"edit": "",
"export": "",
"export-all": "",
+ "loading": "",
"view": ""
},
"contact-points": {
@@ -329,6 +330,14 @@
"save": "",
"saving": ""
},
+ "notification-preview": {
+ "alertmanager": "",
+ "error": "",
+ "initialized": "",
+ "preview-routing": "",
+ "title": "",
+ "uninitialized": ""
+ },
"policies": {
"default-policy": {
"description": "",
diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json
index d0345b0674f..86256c0ee9e 100644
--- a/public/locales/fr-FR/grafana.json
+++ b/public/locales/fr-FR/grafana.json
@@ -245,6 +245,7 @@
"edit": "",
"export": "",
"export-all": "",
+ "loading": "",
"view": ""
},
"contact-points": {
@@ -329,6 +330,14 @@
"save": "",
"saving": ""
},
+ "notification-preview": {
+ "alertmanager": "",
+ "error": "",
+ "initialized": "",
+ "preview-routing": "",
+ "title": "",
+ "uninitialized": ""
+ },
"policies": {
"default-policy": {
"description": "",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index e642ff56ecb..71c193eb5b1 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -245,6 +245,7 @@
"edit": "Ēđįŧ",
"export": "Ēχpőřŧ",
"export-all": "Ēχpőřŧ äľľ",
+ "loading": "Ŀőäđįʼnģ...",
"view": "Vįęŵ"
},
"contact-points": {
@@ -329,6 +330,14 @@
"save": "Ŝävę mūŧę ŧįmįʼnģ",
"saving": "Ŝävįʼnģ mūŧę ŧįmįʼnģ"
},
+ "notification-preview": {
+ "alertmanager": "Åľęřŧmäʼnäģęř:",
+ "error": "Cőūľđ ʼnőŧ ľőäđ řőūŧįʼnģ přęvįęŵ ƒőř {{alertmanager}}",
+ "initialized": "ßäşęđ őʼn ŧĥę ľäþęľş äđđęđ, äľęřŧ įʼnşŧäʼnčęş äřę řőūŧęđ ŧő ŧĥę ƒőľľőŵįʼnģ ʼnőŧįƒįčäŧįőʼn pőľįčįęş. Ēχpäʼnđ ęäčĥ ʼnőŧįƒįčäŧįőʼn pőľįčy þęľőŵ ŧő vįęŵ mőřę đęŧäįľş.",
+ "preview-routing": "Přęvįęŵ řőūŧįʼnģ",
+ "title": "Åľęřŧ įʼnşŧäʼnčę řőūŧįʼnģ přęvįęŵ",
+ "uninitialized": "Ŵĥęʼn yőū ĥävę yőūř ƒőľđęř şęľęčŧęđ äʼnđ yőūř qūęřy äʼnđ ľäþęľş äřę čőʼnƒįģūřęđ, čľįčĸ \"Přęvįęŵ řőūŧįʼnģ\" ŧő şęę ŧĥę řęşūľŧş ĥęřę."
+ },
"policies": {
"default-policy": {
"description": "Åľľ äľęřŧ įʼnşŧäʼnčęş ŵįľľ þę ĥäʼnđľęđ þy ŧĥę đęƒäūľŧ pőľįčy įƒ ʼnő őŧĥęř mäŧčĥįʼnģ pőľįčįęş äřę ƒőūʼnđ.",
diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json
index b43369d5648..712456d7217 100644
--- a/public/locales/pt-BR/grafana.json
+++ b/public/locales/pt-BR/grafana.json
@@ -245,6 +245,7 @@
"edit": "",
"export": "",
"export-all": "",
+ "loading": "",
"view": ""
},
"contact-points": {
@@ -329,6 +330,14 @@
"save": "",
"saving": ""
},
+ "notification-preview": {
+ "alertmanager": "",
+ "error": "",
+ "initialized": "",
+ "preview-routing": "",
+ "title": "",
+ "uninitialized": ""
+ },
"policies": {
"default-policy": {
"description": "",
diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json
index de69d235d0a..0518af6ce7c 100644
--- a/public/locales/zh-Hans/grafana.json
+++ b/public/locales/zh-Hans/grafana.json
@@ -245,6 +245,7 @@
"edit": "",
"export": "",
"export-all": "",
+ "loading": "",
"view": ""
},
"contact-points": {
@@ -327,6 +328,14 @@
"save": "",
"saving": ""
},
+ "notification-preview": {
+ "alertmanager": "",
+ "error": "",
+ "initialized": "",
+ "preview-routing": "",
+ "title": "",
+ "uninitialized": ""
+ },
"policies": {
"default-policy": {
"description": "",
diff --git a/yarn.lock b/yarn.lock
index 25c97c76c0c..a09b6ed506e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3776,11 +3776,11 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/scenes-react@npm:5.26.0":
- version: 5.26.0
- resolution: "@grafana/scenes-react@npm:5.26.0"
+"@grafana/scenes-react@npm:5.28.1":
+ version: 5.28.1
+ resolution: "@grafana/scenes-react@npm:5.28.1"
dependencies:
- "@grafana/scenes": "npm:5.26.0"
+ "@grafana/scenes": "npm:5.28.1"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3791,13 +3791,13 @@ __metadata:
"@grafana/ui": ^11.0.0
react: ^18.0.0
react-dom: ^18.0.0
- checksum: 10/57bc608902b4b07f090bd03e7148d21264796d8412072e0d887672d8d6271e2fdd0c1417b1622c8339f2411da34da8a208ce6f85914ddcc792e61c157d2ef90f
+ checksum: 10/1979eebf0eea30550e9ffc1c9a766cd239914f5b79e0487ddf77d49006288c78f2e3f72bbe97782b6144902ef05e649f6f46197891811e3bbf809e6d016f0920
languageName: node
linkType: hard
-"@grafana/scenes@npm:5.26.0":
- version: 5.26.0
- resolution: "@grafana/scenes@npm:5.26.0"
+"@grafana/scenes@npm:5.28.1":
+ version: 5.28.1
+ resolution: "@grafana/scenes@npm:5.28.1"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3814,7 +3814,7 @@ __metadata:
"@grafana/ui": ">=10.4"
react: ^18.0.0
react-dom: ^18.0.0
- checksum: 10/b1aebcb7aafdbbd6090af75f2b2e38d3fda5789efc1c6cd586e715cc9d32210b4f2340b7ddd96fe3eadb19355dba7a1f5e067e152b4107855a98fca932c60e4a
+ checksum: 10/a12ab38c048e886a880bff64c5b17ae959f34efea44bee06afa4acdc4dcde14ccafc7aa263df568f54d7d5dd165d40952f11ce0ee0c1daa3ad46ea435dbe00ad
languageName: node
linkType: hard
@@ -17670,8 +17670,8 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
- "@grafana/scenes": "npm:5.26.0"
- "@grafana/scenes-react": "npm:5.26.0"
+ "@grafana/scenes": "npm:5.28.1"
+ "@grafana/scenes-react": "npm:5.28.1"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0"