diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts
index dec175674ce..6dcc5a186e6 100644
--- a/packages/grafana-data/src/index.ts
+++ b/packages/grafana-data/src/index.ts
@@ -571,6 +571,7 @@ export {
PluginExtensionTypes,
PluginExtensionPoints,
PluginExtensionExposedComponents,
+ PluginExtensionPointPatterns,
type PluginExtension,
type PluginExtensionLink,
type PluginExtensionComponent,
diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts
index 2461e8283db..52fb88e57df 100644
--- a/packages/grafana-data/src/types/pluginExtensions.ts
+++ b/packages/grafana-data/src/types/pluginExtensions.ts
@@ -208,6 +208,13 @@ export enum PluginExtensionPoints {
ExtensionSidebar = 'grafana/extension-sidebar/v0-alpha',
}
+// Don't use directly in a plugin!
+// Extension point IDs that contain dynamic segments and are not valid as static values — they require runtime substitution of certain parts.
+// (They cannot be used as is. E.g. "grafana/nav-landing-page/.*/v1" becomes "grafana/nav-landing-page/observability/v1" during runtime.)
+export enum PluginExtensionPointPatterns {
+ NavLandingPage = 'grafana/dynamic/nav-landing-page/nav-id-.*/v1',
+}
+
// Extension Points available in plugins
export enum PluginExtensionExposedComponents {
CentralAlertHistorySceneV1 = 'grafana/central-alert-history-scene/v1',
diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go
index 8226304388a..b54e679de9c 100644
--- a/pkg/services/navtree/navtreeimpl/applinks.go
+++ b/pkg/services/navtree/navtreeimpl/applinks.go
@@ -236,7 +236,7 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n
treeRoot.AddSection(&navtree.NavLink{
Text: "Observability",
Id: navtree.NavIDObservability,
- SubTitle: "Opinionated observability across applications, services, and infrastructure",
+ SubTitle: "Monitor infrastructure and applications in real time with Grafana Cloud's fully managed observability suite",
Icon: "heart-rate",
SortWeight: navtree.WeightObservability,
Children: []*navtree.NavLink{appLink},
diff --git a/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx b/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx
index caf11f3a48f..0c0ae6987a8 100644
--- a/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx
+++ b/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx
@@ -1,11 +1,19 @@
import { render, screen } from '@testing-library/react';
import { TestProvider } from 'test/helpers/TestProvider';
-import { config } from '@grafana/runtime';
+import { config, setPluginComponentsHook } from '@grafana/runtime';
+import { createComponentWithMeta } from 'app/features/plugins/extensions/usePluginComponents';
import { NavLandingPage } from './NavLandingPage';
describe('NavLandingPage', () => {
+ beforeEach(() => {
+ setPluginComponentsHook(() => ({
+ components: [],
+ isLoading: false,
+ }));
+ });
+
const mockSectionTitle = 'Section title';
const mockId = 'section';
const mockSectionUrl = 'mock-section-url';
@@ -83,4 +91,23 @@ describe('NavLandingPage', () => {
setup(true);
expect(screen.getByRole('heading', { name: 'Custom Header' })).toBeInTheDocument();
});
+
+ it('renders the ObservabilityLandingPage when the path is /observability', () => {
+ setPluginComponentsHook(() => ({
+ components: [
+ createComponentWithMeta(
+ {
+ title: 'Landing Page',
+ description: 'Landing Page description',
+ component: () =>
ObservabilityLandingPage
,
+ pluginId: 'grafana-plugin-app',
+ },
+ 'grafana/dynamic/nav-landing-page/nav-id-observability/v1'
+ ),
+ ],
+ isLoading: false,
+ }));
+ setup();
+ expect(screen.getByText('ObservabilityLandingPage')).toBeInTheDocument();
+ });
});
diff --git a/public/app/core/components/NavLandingPage/NavLandingPage.tsx b/public/app/core/components/NavLandingPage/NavLandingPage.tsx
index 3e8d4683cfa..4dd7a53d67a 100644
--- a/public/app/core/components/NavLandingPage/NavLandingPage.tsx
+++ b/public/app/core/components/NavLandingPage/NavLandingPage.tsx
@@ -1,7 +1,8 @@
import { css } from '@emotion/css';
import * as React from 'react';
-import { GrafanaTheme2 } from '@grafana/data';
+import { GrafanaTheme2, NavModelItem } from '@grafana/data';
+import { usePluginComponents } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/core/hooks/useNavModel';
@@ -13,29 +14,45 @@ interface Props {
header?: React.ReactNode;
}
+const EXTENSION_ID = (nodeId: string) => `grafana/dynamic/nav-landing-page/nav-id-${nodeId}/v1`;
+
export function NavLandingPage({ navId, header }: Props) {
const { node } = useNavModel(navId);
const styles = useStyles2(getStyles);
const children = node.children?.filter((child) => !child.hideFromTabs);
+ const { components, isLoading } = usePluginComponents<{
+ node: NavModelItem;
+ }>({
+ extensionPointId: EXTENSION_ID(node.id ?? ''),
+ });
+
+ if (isLoading) {
+ return null;
+ }
+
return (
-
- {header}
- {children && children.length > 0 && (
-
- {children?.map((child) => (
-
- ))}
-
- )}
-
+ {components?.length > 0 ? (
+ components.map((Component, idx) => )
+ ) : (
+
+ {header}
+ {children && children.length > 0 && (
+
+ {children?.map((child) => (
+
+ ))}
+
+ )}
+
+ )}
);
diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx
index 2f1713d3f34..9f3dcca5fb4 100644
--- a/public/app/features/plugins/extensions/validators.test.tsx
+++ b/public/app/features/plugins/extensions/validators.test.tsx
@@ -211,6 +211,7 @@ describe('Plugin Extension Validators', () => {
['plugins/grafana-oncall-app/alert-group/action', 'grafana-oncall-app'],
['plugins/grafana-oncall-app/alert-group/action/v1', 'grafana-oncall-app'],
['plugins/grafana-oncall-app/alert-group/action/v1.0.0', 'grafana-oncall-app'],
+ ['grafana/dynamic/nav-landing-page/nav-id-observability/v1', 'grafana'], // this a dynamic (runtime evaluated) extension point id
])('should return TRUE if the extension point id is valid ("%s", "%s")', (extensionPointId, pluginId) => {
expect(
isExtensionPointIdValid({
diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts
index df808a34576..9661b5e73b6 100644
--- a/public/app/features/plugins/extensions/validators.ts
+++ b/public/app/features/plugins/extensions/validators.ts
@@ -7,6 +7,7 @@ import {
type PluginExtensionExposedComponentConfig,
type PluginExtensionAddedFunctionConfig,
PluginExtensionPoints,
+ PluginExtensionPointPatterns,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc } from '@grafana/data/internal';
import { config, isPluginExtensionLink } from '@grafana/runtime';
@@ -91,7 +92,13 @@ export function isExtensionPointIdValid({
return false;
}
- if (!isInsidePlugin && !Object.values(PluginExtensionPoints).includes(extensionPointId)) {
+ if (
+ !isInsidePlugin &&
+ !Object.values(PluginExtensionPoints).includes(extensionPointId) &&
+ !Object.values(PluginExtensionPointPatterns).some((extensionPointPattern) =>
+ extensionPointId.match(extensionPointPattern)
+ )
+ ) {
log.error(errors.INVALID_EXTENSION_POINT_ID_GRAFANA_EXPOSED);
return false;
}