diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx b/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx
index 25d69140a96..89ed1af12c4 100644
--- a/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx
+++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx
@@ -78,6 +78,13 @@ export const plugin = new AppPlugin<{}>()
return undefined;
}
},
+ })
+ .addLink({
+ targets: 'grafana/dynamic/nav-landing-page/nav-id-cfg/cards/v1',
+ title: 'Extensions Test App',
+ description: 'This card tests the admin page card extension point',
+ path: `/a/${pluginJson.id}/`,
+ category: 'error',
});
function isSupported(context?: PluginExtensionPanelContext): boolean {
diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json b/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json
index 57fa87dcde5..3f5adfa215a 100644
--- a/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json
+++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json
@@ -55,6 +55,11 @@
"targets": ["grafana/dashboard/panel/menu"],
"title": "Open from time series or pie charts (onClick)",
"description": "This link will only be visible on time series and pie charts"
+ },
+ {
+ "targets": ["grafana/dynamic/nav-landing-page/nav-id-cfg/cards/v1"],
+ "title": "Extensions Test App",
+ "description": "This card tests the admin page card extension point"
}
],
"extensionPoints": [
diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts
index 4b988c49a0a..05e7b2b144e 100644
--- a/packages/grafana-data/src/types/pluginExtensions.ts
+++ b/packages/grafana-data/src/types/pluginExtensions.ts
@@ -223,8 +223,13 @@ export enum PluginExtensionPoints {
// 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.)
+//
+// IMPORTANT: NavLandingPage and NavLandingPageCards are mutually exclusive.
+// If a plugin extends NavLandingPage, it will replace the entire page content and any NavLandingPageCards extensions will be ignored.
+// Only use NavLandingPageCards if you want to add additional cards to the default landing page layout.
export enum PluginExtensionPointPatterns {
NavLandingPage = 'grafana/dynamic/nav-landing-page/nav-id-.*/v1',
+ NavLandingPageCards = 'grafana/dynamic/nav-landing-page/nav-id-.*/cards/v1',
}
// Extension Points available in plugins
diff --git a/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx b/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx
index 0c0ae6987a8..849cfc6d3c8 100644
--- a/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx
+++ b/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { TestProvider } from 'test/helpers/TestProvider';
-import { config, setPluginComponentsHook } from '@grafana/runtime';
+import { config, setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
import { createComponentWithMeta } from 'app/features/plugins/extensions/usePluginComponents';
import { NavLandingPage } from './NavLandingPage';
@@ -12,6 +12,10 @@ describe('NavLandingPage', () => {
components: [],
isLoading: false,
}));
+ setPluginLinksHook(() => ({
+ links: [],
+ isLoading: false,
+ }));
});
const mockSectionTitle = 'Section title';
diff --git a/public/app/core/components/NavLandingPage/NavLandingPage.tsx b/public/app/core/components/NavLandingPage/NavLandingPage.tsx
index 4dd7a53d67a..769c37334bc 100644
--- a/public/app/core/components/NavLandingPage/NavLandingPage.tsx
+++ b/public/app/core/components/NavLandingPage/NavLandingPage.tsx
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
-import { usePluginComponents } from '@grafana/runtime';
+import { usePluginComponents, usePluginLinks } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/core/hooks/useNavModel';
@@ -15,6 +15,7 @@ interface Props {
}
const EXTENSION_ID = (nodeId: string) => `grafana/dynamic/nav-landing-page/nav-id-${nodeId}/v1`;
+const CARDS_EXTENSION_ID = (nodeId: string) => `grafana/dynamic/nav-landing-page/nav-id-${nodeId}/cards/v1`;
export function NavLandingPage({ navId, header }: Props) {
const { node } = useNavModel(navId);
@@ -27,7 +28,23 @@ export function NavLandingPage({ navId, header }: Props) {
extensionPointId: EXTENSION_ID(node.id ?? ''),
});
- if (isLoading) {
+ const { links: additionalCards, isLoading: isLoadingCards } = usePluginLinks({
+ extensionPointId: CARDS_EXTENSION_ID(node.id ?? ''),
+ context: { node },
+ });
+
+ // Warn if both extension points are being used (they are mutually exclusive)
+ React.useEffect(() => {
+ if (components && components.length > 0 && additionalCards && additionalCards.length > 0) {
+ console.warn(
+ `[NavLandingPage] Both NavLandingPage and NavLandingPageCards extensions are registered for "${node.id}". ` +
+ `The NavLandingPage extension will take precedence and NavLandingPageCards will be ignored. ` +
+ `Please use only one extension point.`
+ );
+ }
+ }, [components, additionalCards, node.id]);
+
+ if (isLoading || isLoadingCards) {
return null;
}
@@ -49,6 +66,16 @@ export function NavLandingPage({ navId, header }: Props) {
url={child.url ?? ''}
/>
))}
+ {additionalCards?.map((link) => (
+
+ ))}
)}
diff --git a/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx b/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx
index 4eb223b4f59..3897066cbd7 100644
--- a/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx
+++ b/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx
@@ -1,4 +1,5 @@
-import { css } from '@emotion/css';
+import { css, cx } from '@emotion/css';
+import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, useStyles2 } from '@grafana/ui';
@@ -7,12 +8,24 @@ interface Props {
description?: string;
text: string;
url: string;
+ category?: string;
+ onClick?: (event?: React.MouseEvent) => void;
}
-export function NavLandingPageCard({ description, text, url }: Props) {
+const CATEGORY_STYLES = ['primary', 'secondary', 'success', 'warning', 'error'] as const;
+type CategoryStyle = (typeof CATEGORY_STYLES)[number];
+
+function isCategoryStyle(cat: string): cat is CategoryStyle {
+ return CATEGORY_STYLES.some((style) => style === cat);
+}
+
+export function NavLandingPageCard({ description, text, url, category, onClick }: Props) {
const styles = useStyles2(getStyles);
+
+ const categoryClass = category && isCategoryStyle(category) ? styles[category] : undefined;
+
return (
-
+
{text}
{description}
@@ -31,4 +44,45 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: '-webkit-box',
overflow: 'hidden',
}),
+ // Category-based styling
+ primary: css({
+ border: `1px solid ${theme.colors.primary.borderTransparent}`,
+ backgroundColor: theme.colors.primary.transparent,
+ '&:hover': {
+ backgroundColor: theme.colors.primary.transparent,
+ borderColor: theme.colors.primary.border,
+ },
+ }),
+ secondary: css({
+ border: `1px solid ${theme.colors.secondary.borderTransparent}`,
+ backgroundColor: theme.colors.secondary.transparent,
+ '&:hover': {
+ backgroundColor: theme.colors.secondary.transparent,
+ borderColor: theme.colors.secondary.border,
+ },
+ }),
+ success: css({
+ border: `1px solid ${theme.colors.success.borderTransparent}`,
+ backgroundColor: theme.colors.success.transparent,
+ '&:hover': {
+ backgroundColor: theme.colors.success.transparent,
+ borderColor: theme.colors.success.border,
+ },
+ }),
+ warning: css({
+ border: `1px solid ${theme.colors.warning.borderTransparent}`,
+ backgroundColor: theme.colors.warning.transparent,
+ '&:hover': {
+ backgroundColor: theme.colors.warning.transparent,
+ borderColor: theme.colors.warning.border,
+ },
+ }),
+ error: css({
+ border: `1px solid ${theme.colors.error.borderTransparent}`,
+ backgroundColor: theme.colors.error.transparent,
+ '&:hover': {
+ backgroundColor: theme.colors.error.transparent,
+ borderColor: theme.colors.error.border,
+ },
+ }),
});