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; }