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 <balogh.levente.hu@gmail.com>
This commit is contained in:
Javier Ruiz 2025-09-10 15:43:36 +02:00 committed by GitHub
parent 0d6827eb75
commit 646dd8de06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 79 additions and 19 deletions

View File

@ -571,6 +571,7 @@ export {
PluginExtensionTypes,
PluginExtensionPoints,
PluginExtensionExposedComponents,
PluginExtensionPointPatterns,
type PluginExtension,
type PluginExtensionLink,
type PluginExtensionComponent,

View File

@ -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',

View File

@ -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},

View File

@ -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: () => <div>ObservabilityLandingPage</div>,
pluginId: 'grafana-plugin-app',
},
'grafana/dynamic/nav-landing-page/nav-id-observability/v1'
),
],
isLoading: false,
}));
setup();
expect(screen.getByText('ObservabilityLandingPage')).toBeInTheDocument();
});
});

View File

@ -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 (
<Page navId={node.id}>
<Page.Contents>
<div className={styles.content}>
{header}
{children && children.length > 0 && (
<section className={styles.grid}>
{children?.map((child) => (
<NavLandingPageCard
key={child.id}
description={child.subTitle}
text={child.text}
url={child.url ?? ''}
/>
))}
</section>
)}
</div>
{components?.length > 0 ? (
components.map((Component, idx) => <Component key={idx} node={node} />)
) : (
<div className={styles.content}>
{header}
{children && children.length > 0 && (
<section className={styles.grid}>
{children?.map((child) => (
<NavLandingPageCard
key={child.id}
description={child.subTitle}
text={child.text}
url={child.url ?? ''}
/>
))}
</section>
)}
</div>
)}
</Page.Contents>
</Page>
);

View File

@ -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({

View File

@ -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<string>(PluginExtensionPoints).includes(extensionPointId)) {
if (
!isInsidePlugin &&
!Object.values<string>(PluginExtensionPoints).includes(extensionPointId) &&
!Object.values<string>(PluginExtensionPointPatterns).some((extensionPointPattern) =>
extensionPointId.match(extensionPointPattern)
)
) {
log.error(errors.INVALID_EXTENSION_POINT_ID_GRAFANA_EXPOSED);
return false;
}