From 646dd8de06745655d72a1a27d4d22186f6acc347 Mon Sep 17 00:00:00 2001 From: Javier Ruiz Date: Wed, 10 Sep 2025 15:43:36 +0200 Subject: [PATCH] PluginExtensions: Add extension point for overriding Observability home page (#110500) * feat/add_observability_landing * Add check for observability path * Fix existing tests * Test that we're rendering the component when in the correct path * Reset all mocks after testing * Check for extension only on observability route * Undo changes to tests * Extract strings to constants * Remove unused validator * Remove unnecesary ObservabilityLanding component * Update subtitle for Observability section * Use proper ' * Expose extension point, allow plugins to hook into it, and render received components * Fix and test * Remove no longer needed unit tests * Readd validation checks, allow for regex like paths * refactor(extensions): extract dynamic extension point ids to a separate enum * Undo unwanted const to let change * Update extension point id to better transmit intent and use --------- Co-authored-by: Levente Balogh --- packages/grafana-data/src/index.ts | 1 + .../src/types/pluginExtensions.ts | 7 +++ pkg/services/navtree/navtreeimpl/applinks.go | 2 +- .../NavLandingPage/NavLandingPage.test.tsx | 29 ++++++++++- .../NavLandingPage/NavLandingPage.tsx | 49 +++++++++++++------ .../plugins/extensions/validators.test.tsx | 1 + .../features/plugins/extensions/validators.ts | 9 +++- 7 files changed, 79 insertions(+), 19 deletions(-) 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; }