Dashboard Library: Integrate template dashboards (#112906)

* Extend interpolate endpoint to support community dashboard json interpolation
Added unit tests

* Implement Frontend Side
- Show tabs
- Fetch Community dashboads
- basic cards for community
- Search bar for community

* Improve card community and show thumbnails

* quick poc with template dashboards

* better cards ui

* entry point conditional added. dashboard card improved

* dev dashboard for testing

* details removed in template dashboard modal

* improvement when loading the templates

* dashboard from template entry points

* interactions added

* tracking event improvements. card improvement

* unused import removed

* 90% Complete MVP community dashboards integraton
- Use DashboardCard component
- Search grafana dashboard in the community tab
- Make Tabs and pagination sticky
- Adjust titles to be scoped by datasource name/type
- Add skeleton loading for community tabs and pagination
- Add dashboard details tooltip

* Use DataSourcePicker for extra configuration page

* Fix suggested dashboard, don't use filter but slug and also add gcomURLink

* Implement badge, for now reuse Badge component

* translations added

* Refactor code, extract to utils function and clean up code

* refactor provisioned dashboards images

* add missing file

* Extract API functions

* standarize event handlers

* Auto select tab when provisioned or community are not present, also add empty state for provisioned tab

* use SecondaryAction iconButton for Details, and also use Tooltip

* For suggested dashboards change Grid for Stack to fix issue with less than 3 dashboards, also add the details next to
the main action

* Fix styling issue with description miss-aligment

* Change "use template" to use dashboard

* update i18n

* fix broken unit test

* FF added

* uid changed to make it work for both scenarios

* Apply PR feedback
- add eventLocation
- Fix description placeholder
- Fix issue with suggested dashboards card aligment, change to Grid

* use datasource type instead of name, extract i18n and fix linting

* Improve View on Grafana.com link

* remove console.log

* Fix issue with cards styling

* improvements

* Apply suggestion from @juanicabanas

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>

* Use query params for modal state and reorder dashboard empty to keep suggestions in the middle

* extract common interpolate logic in new function

* fix linting

* rename loadTemplateDashboard function to loadSuggestedDashboard

* Improve automapping layout, add dashboard name to title and pipe the mappings

* Apply style guide for callback functions, and refactor constant inputs initialization wiht reduce

* Fix styling issues with focus border and pagination

* fix issue with card aligment

* use attach skeleton instead of custom css

* Adjust all i18n to use dashboard-library key

* Refactor mapping form to use one array to handle unmapped ds

* Update public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>

* Refactor pagination, use api pagination data

* Remove unnecesary trim

* Fix issue with Stack height and justifyContent in modal

* Update public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>

* Add missing dependency

* Fix issue with mapping config form, make buttons to be aligned at the bottom

* Fix unit tests

* Add and refactor tracking interactions to support experiment KPIS

* rename unmappedInputs to unmappedDsInputs for clarity

* Update public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>

* rename modal and move modal to section

* Simplify search params, extrack user selected dashboards to helper function, update pagination styles and use url based
pagination

* Move modal logic to the suggested dashboards component

* Fix tracking duplication on first load and revert change on pagination url persistence

* Extrac on preview community dashboard into utils

* Bring old datasource-provisioned box back and rely on new feature toggle for community dashboards

* change logic for showing suggested dashboards, we only need to enable that feature toggle

* update i18n

* Fix unit test for basic provisioned dashboard

* fix css

* Apply feedback

* Add suggested dashboards to endpoint

* Add missing feature toggle check in the backend to expose the interpolate api

* Add extra test case

* update swagger

* Update public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>

* update swagger

* changes applied to retrieve templates dashboards from raintank

* translations

* Remove duplicated tracking and add gnet id to the save tracking action

* interactions

* improvements applied

* last improvements

* tracking events modified with merge. translations fixed

* tests fixed

* uid property removed from dto. new way of tracking the ds types added

* ds types from gnet dashboard removed

* fixes

* tracking changed

* tracking modified

---------

Co-authored-by: alexandra vargas <alexa1866@gmail.com>
Co-authored-by: Alexa Vargas <239999+axelavargas@users.noreply.github.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Juan Cabanas
2025-11-14 15:22:48 -03:00
committed by GitHub
co-authored by alexandra vargas Alexa Vargas nmarrs
parent 4581ed89a0
commit de45393eb5
20 changed files with 472 additions and 87 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteR
routing.Wrap(api.ImportDashboard),
)
//nolint:staticcheck // not yet migrated to OpenFeature
if api.features.IsEnabledGlobally(featuremgmt.FlagDashboardLibrary) || api.features.IsEnabledGlobally(featuremgmt.FlagSuggestedDashboards) {
if api.features.IsEnabledGlobally(featuremgmt.FlagDashboardLibrary) || api.features.IsEnabledGlobally(featuremgmt.FlagSuggestedDashboards) || api.features.IsEnabledGlobally(featuremgmt.FlagDashboardTemplates) {
route.Post(
"/interpolate",
authorize(accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)),
@@ -1,8 +1,13 @@
import { useMemo, useState } from 'react';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getDataSourceSrv, reportInteraction, config } from '@grafana/runtime';
import { Menu, Dropdown, ToolbarButton } from '@grafana/ui';
import {
CONTENT_KINDS,
DashboardLibraryInteractions,
SOURCE_ENTRY_POINTS,
} from 'app/features/dashboard/dashgrid/DashboardLibrary/interactions';
import { useSelector } from 'app/types/store';
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
@@ -15,7 +20,28 @@ export const QuickAdd = ({}: Props) => {
const navBarTree = useSelector((state) => state.navBarTree);
const [isOpen, setIsOpen] = useState(false);
const createActions = useMemo(() => findCreateActions(navBarTree), [navBarTree]);
const createActions = useMemo(() => {
const createActions = findCreateActions(navBarTree);
if (config.featureToggles.dashboardTemplates) {
const testDataSources = getDataSourceSrv().getList({ type: 'grafana-testdata-datasource' });
if (testDataSources.length > 0) {
createActions.splice(1, 0, {
id: 'browse-template-dashboard',
text: t('navigation.quick-add.new-template-dashboard-button', 'Dashboard from template'),
url: '/dashboards?templateDashboards=true&source=quickAdd',
onClick: () => {
DashboardLibraryInteractions.entryPointClicked({
entryPoint: SOURCE_ENTRY_POINTS.QUICK_ADD_BUTTON,
contentKind: CONTENT_KINDS.TEMPLATE_DASHBOARD,
});
},
});
}
}
return createActions;
}, [navBarTree]);
const showQuickAdd = createActions.length > 0;
if (!showQuickAdd) {
@@ -30,7 +56,10 @@ export const QuickAdd = ({}: Props) => {
key={index}
url={createAction.url}
label={createAction.text}
onClick={() => reportInteraction('grafana_menu_item_clicked', { url: createAction.url, from: 'quickadd' })}
onClick={() => {
reportInteraction('grafana_menu_item_clicked', { url: createAction.url, from: 'quickadd' });
createAction.onClick?.();
}}
/>
))}
</Menu>
@@ -1,4 +1,5 @@
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
// Because the navigation content is dynamic (defined in the backend), we can not use
// the normal inline message definition method.
@@ -48,7 +49,9 @@ export function getNavTitle(navId: string | undefined) {
case 'dashboards/recently-deleted':
return t('nav.recently-deleted.title', 'Recently deleted');
case 'dashboards/new':
return t('nav.new-dashboard.title', 'New dashboard');
return config.featureToggles.dashboardTemplates
? t('nav.new-dashboard.empty-title', 'Empty dashboard')
: t('nav.new-dashboard.title', 'New dashboard');
case 'dashboards/folder/new':
return t('nav.new-folder.title', 'New folder');
case 'dashboards/import':
@@ -15,6 +15,7 @@ import { useDispatch } from 'app/types/store';
import { FolderRepo } from '../../core/components/NestedFolderPicker/FolderRepo';
import { contextSrv } from '../../core/services/context_srv';
import { ManagerKind } from '../apiserver/types';
import { TemplateDashboardModal } from '../dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal';
import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel';
import { ProvisionedFolderPreviewBanner } from '../provisioning/components/Folders/ProvisionedFolderPreviewBanner';
import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView';
@@ -217,6 +218,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
}
</AutoSizer>
</div>
{config.featureToggles.dashboardTemplates && <TemplateDashboardModal />}
</Page.Contents>
</Page>
);
@@ -2,10 +2,15 @@ import { useState } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { locationUtil } from '@grafana/data';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { config, getDataSourceSrv, locationService, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { useAppNotification } from 'app/core/copy/appNotification';
import {
CONTENT_KINDS,
DashboardLibraryInteractions,
SOURCE_ENTRY_POINTS,
} from 'app/features/dashboard/dashgrid/DashboardLibrary/interactions';
import { RepoType } from 'app/features/provisioning/Wizard/types';
import { NewProvisionedFolderForm } from 'app/features/provisioning/components/Folders/NewProvisionedFolderForm';
import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance';
@@ -15,6 +20,7 @@ import {
getNewDashboardPhrase,
getNewFolderPhrase,
getNewPhrase,
getNewTemplateDashboardPhrase,
} from 'app/features/search/tempI18nPhrases';
import { FolderDTO } from 'app/types/folders';
@@ -44,6 +50,12 @@ export default function CreateNewButton({
const notifyApp = useAppNotification();
const isProvisionedInstance = useIsProvisionedInstance();
let renderPreBuiltDashboardAction = false;
if (config.featureToggles.dashboardTemplates) {
const testDataSources = getDataSourceSrv().getList({ type: 'grafana-testdata-datasource' });
renderPreBuiltDashboardAction = testDataSources.length > 0;
}
const onCreateFolder = async (folderName: string) => {
try {
const folder = await newFolder({
@@ -74,16 +86,30 @@ export default function CreateNewButton({
const newMenu = (
<Menu>
{canCreateDashboard && (
<MenuItem
label={getNewDashboardPhrase()}
onClick={() =>
reportInteraction('grafana_menu_item_clicked', {
url: buildUrl('/dashboard/new', parentFolder?.uid),
from: location.pathname,
})
}
url={buildUrl('/dashboard/new', parentFolder?.uid)}
/>
<>
<MenuItem
label={getNewDashboardPhrase()}
onClick={() =>
reportInteraction('grafana_menu_item_clicked', {
url: buildUrl('/dashboard/new', parentFolder?.uid),
from: location.pathname,
})
}
url={buildUrl('/dashboard/new', parentFolder?.uid)}
/>
{renderPreBuiltDashboardAction && (
<MenuItem
label={getNewTemplateDashboardPhrase()}
onClick={() =>
DashboardLibraryInteractions.entryPointClicked({
entryPoint: SOURCE_ENTRY_POINTS.BROWSE_DASHBOARDS_PAGE,
contentKind: CONTENT_KINDS.TEMPLATE_DASHBOARD,
})
}
url={buildUrl('/dashboards?templateDashboards=true&source=createNewButton', parentFolder?.uid)}
/>
)}
</>
)}
{canCreateFolder && <MenuItem onClick={() => setShowNewFolderDrawer(true)} label={getNewFolderPhrase()} />}
{canCreateDashboard && !isProvisionedInstance && parentFolder?.managedBy !== ManagerKind.Repo && (
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getDataSourceSrv, config, locationService } from '@grafana/runtime';
import { getEnrichedHelpItem } from 'app/core/components/AppChrome/MegaMenu/utils';
import {
shouldRenderInviteUserButton,
@@ -9,6 +10,11 @@ import {
} from 'app/core/components/AppChrome/TopBar/InviteUserButtonUtils';
import { changeTheme } from 'app/core/services/theme';
import { currentMockApiState, toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils';
import {
CONTENT_KINDS,
DashboardLibraryInteractions,
SOURCE_ENTRY_POINTS,
} from 'app/features/dashboard/dashgrid/DashboardLibrary/interactions';
import { useSelector } from 'app/types/store';
import { CommandPaletteAction } from '../types';
@@ -140,7 +146,31 @@ function getGlobalActions(): CommandPaletteAction[] {
export function useStaticActions(): CommandPaletteAction[] {
const navBarTree = useSelector((state) => state.navBarTree);
return useMemo(() => {
const navBarActions = navTreeToActions(navBarTree);
let navBarActions = navTreeToActions(navBarTree);
if (config.featureToggles.dashboardTemplates) {
const testDataSources = getDataSourceSrv().getList({ type: 'grafana-testdata-datasource' });
if (testDataSources.length > 0) {
const navBarActionsWithoutActions = navBarActions.filter((action) => action.priority !== ACTIONS_PRIORITY);
const navBarActionsWithActions = navBarActions.filter((action) => action.priority === ACTIONS_PRIORITY);
navBarActionsWithActions.splice(1, 0, {
id: 'browse-template-dashboard',
name: t('command-palette.action.dashboard-from-template', 'Dashboard from template'),
section: t('command-palette.section.actions', 'Actions'),
priority: ACTIONS_PRIORITY,
perform: () => {
DashboardLibraryInteractions.entryPointClicked({
entryPoint: SOURCE_ENTRY_POINTS.COMMAND_PALETTE,
contentKind: CONTENT_KINDS.TEMPLATE_DASHBOARD,
});
locationService.push('/dashboards?templateDashboards=true&source=commandPalette');
},
});
navBarActions = [...navBarActionsWithoutActions, ...navBarActionsWithActions];
}
}
if (shouldRenderInviteUserButton()) {
navBarActions.push({
@@ -4,8 +4,8 @@ import { config, getBackendSrv, getDataSourceSrv, isFetchError, locationService
import { sceneGraph } from '@grafana/scenes';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { GetRepositoryFilesWithPathApiResponse, provisioningAPIv0alpha1 } from 'app/api/clients/provisioning/v0alpha1';
import { contextSrv } from 'app/core/core';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { contextSrv } from 'app/core/services/context_srv';
import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import {
@@ -444,7 +444,12 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
const scene = transformSaveModelToScene(rsp);
// Special handling for Template route - set up edit mode and dirty state
if (config.featureToggles.dashboardLibrary && options.route === DashboardRoutes.Template) {
if (
(config.featureToggles.dashboardLibrary ||
config.featureToggles.suggestedDashboards ||
config.featureToggles.dashboardTemplates) &&
options.route === DashboardRoutes.Template
) {
scene.setInitialSaveModel(rsp.dashboard, rsp.meta);
scene.onEnterEditMode();
scene.setState({ isDirty: true });
@@ -496,14 +501,20 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
};
}
private async loadSuggestedDashboard(): Promise<DashboardDTO> {
private async loadDashboardLibrary(): Promise<DashboardDTO> {
// Extract template parameters from URL
const searchParams = new URLSearchParams(window.location.search);
const datasource = searchParams.get('datasource');
const gnetId = searchParams.get('gnetId');
const pluginId = searchParams.get('pluginId');
const path = searchParams.get('path');
// Check if this is a template dashboard
if (gnetId && datasource && pluginId) {
return this.loadTemplateDashboard(gnetId, datasource, pluginId);
}
// Check if this is a community dashboard (has gnetId) or plugin dashboard
if (gnetId) {
return this.loadCommunityTemplateDashboard(gnetId);
@@ -537,6 +548,34 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
return this.buildDashboardDTOFromInterpolated(interpolatedDashboard);
}
private async loadTemplateDashboard(
gnetId: string,
datasource: string | null,
pluginId: string | null
): Promise<DashboardDTO> {
// Fetch the community dashboard from grafana.com
const gnetDashboard = await getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`);
// The dashboard JSON is in the 'json' property
const dashboardJson = gnetDashboard.json;
const data = {
dashboard: dashboardJson,
overwrite: true,
inputs: [
{
name: '*',
type: 'datasource',
pluginId: pluginId,
value: datasource,
},
],
};
const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data);
return this.buildDashboardDTOFromInterpolated(interpolatedDashboard);
}
private async loadCommunityTemplateDashboard(gnetId: string): Promise<DashboardDTO> {
// Extract mappings from URL params
const location = locationService.getLocation();
@@ -605,7 +644,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
rsp = await buildNewDashboardSaveModel(urlFolderUid);
break;
case DashboardRoutes.Template:
rsp = await this.loadSuggestedDashboard();
rsp = await this.loadDashboardLibrary();
break;
case DashboardRoutes.Provisioning:
return this.loadProvisioningDashboard(slug || '', uid);
@@ -1,6 +1,6 @@
import { store } from '@grafana/data';
import { config } from '@grafana/runtime';
import { extractDatasourceTypesFromUrl } from 'app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers';
import { getDatasourceTypes } from 'app/features/dashboard/dashgrid/DashboardLibrary/utils/dashboardLibraryHelpers';
import { DashboardScene } from '../scene/DashboardScene';
import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
@@ -61,18 +61,21 @@ export function trackDashboardSceneCreatedOrSaved(
const libraryItemId = urlParams.get('libraryItemId') || urlParams.get('gnetId') || undefined;
const creationOrigin = urlParams.get('creationOrigin') || undefined;
// Extract datasourceTypes from URL params (supports both community and provisioned dashboards)
const datasourceTypes = extractDatasourceTypesFromUrl();
// Extract datasourceTypes from URL params (supports both community and provisioned dashboards) or dashboard panels
const datasourceTypes = getDatasourceTypes(dashboard);
const dynamicDashboardsTrackingInformation = dashboard.getDynamicDashboardsTrackingInformation();
const dashboardLibraryProperties = config.featureToggles.dashboardLibrary
? {
datasourceTypes,
sourceEntryPoint,
libraryItemId,
creationOrigin,
}
: {};
const dashboardLibraryProperties =
config.featureToggles.dashboardLibrary ||
config.featureToggles.dashboardTemplates ||
config.featureToggles.suggestedDashboards
? {
datasourceTypes,
sourceEntryPoint,
libraryItemId,
creationOrigin,
}
: {};
DashboardInteractions.dashboardCreatedOrSaved(isNew, {
...initialProperties,
@@ -94,13 +94,13 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
datasourceTypes: [ds.type],
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
hasResults: apiResponse.dashboards.length > 0,
resultCount: apiResponse.dashboards.length,
hasResults: apiResponse.items.length > 0,
resultCount: apiResponse.items.length,
});
}
return {
dashboards: apiResponse.dashboards,
dashboards: apiResponse.items,
pages: apiResponse.pages,
datasourceType: ds.type,
};
@@ -160,7 +160,6 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
return (
<Stack direction="column" gap={2} height="100%">
<FilterInput
className={styles.searchInput}
placeholder={
datasourceType
? t(
@@ -258,7 +257,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
onClick={() => onPreviewCommunityDashboard(dashboard)}
isLogo={isLogo}
details={details}
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
kind="suggested_dashboard"
/>
);
})}
@@ -291,9 +290,5 @@ function getStyles(theme: GrafanaTheme2) {
justifyContent: 'flex-end',
zIndex: 2,
}),
searchInput: css({
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
}),
};
}
@@ -27,7 +27,7 @@ interface Props {
isLogo?: boolean; // Indicates if imageUrl is a small logo vs full screenshot
showDatasourceProvidedBadge?: boolean;
dimThumbnail?: boolean; // Apply 50% opacity to thumbnail when badge is shown
buttonText?: React.ReactNode; // Optional custom button text, defaults to "Use template"
kind: 'template_dashboard' | 'suggested_dashboard';
}
function DashboardCardComponent({
@@ -39,7 +39,7 @@ function DashboardCardComponent({
isLogo,
showDatasourceProvidedBadge,
dimThumbnail,
buttonText,
kind,
}: Props) {
const styles = useStyles2(getStyles);
@@ -53,7 +53,8 @@ function DashboardCardComponent({
alt={title}
className={cx(
isLogo ? styles.logo : styles.thumbnail,
dimThumbnail && showDatasourceProvidedBadge && styles.dimmedImage
dimThumbnail && showDatasourceProvidedBadge && styles.dimmedImage,
kind === 'suggested_dashboard' ? styles.thumbnailCoverImage : styles.thumbnailContainImage
)}
onError={(e) => {
console.error('Failed to load image for:', title, 'URL:', imageUrl);
@@ -81,7 +82,11 @@ function DashboardCardComponent({
</div>
<Card.Actions className={styles.actionsContainer}>
<Button variant="secondary" onClick={onClick}>
{buttonText || <Trans i18nKey="dashboard-library.card.use-template-button">Use template</Trans>}
{kind === 'template_dashboard' ? (
<Trans i18nKey="dashboard-library.card.use-template-button">Use template</Trans>
) : (
<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>
)}
</Button>
{details && (
<Tooltip interactive={true} content={<DetailsTooltipContent details={details} />} placement="right">
@@ -155,6 +160,8 @@ function getStyles(theme: GrafanaTheme2) {
width: '350px',
background: 'transparent',
gridGap: theme.spacing(1),
paddingLeft: 0,
paddingRight: 0,
}),
thumbnailContainer: css({
gridArea: 'Thumbnail',
@@ -175,8 +182,13 @@ function getStyles(theme: GrafanaTheme2) {
thumbnail: css({
width: '100%',
height: '100%',
}),
thumbnailCoverImage: css({
objectFit: 'cover',
}),
thumbnailContainImage: css({
objectFit: 'contain',
}),
logoContainer: css({
gridArea: 'Thumbnail',
overflow: 'hidden',
@@ -215,12 +227,11 @@ function getStyles(theme: GrafanaTheme2) {
}),
description: css({
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
margin: 0,
height: `calc(${theme.typography.body.lineHeight} * 1em)`, // Fixed height for 1 line
}),
actionsContainer: css({
marginTop: 0,
@@ -157,7 +157,7 @@ export const DashboardLibrarySection = () => {
imageUrl={imageUrl}
dashboard={dashboard}
onClick={() => onUseProvisionedDashboard(dashboard)}
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
kind="suggested_dashboard"
/>
);
}) || []}
@@ -98,7 +98,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
}),
]);
const community = communityResponse.dashboards;
const community = communityResponse.items;
// Mix: 1 provisioned + 2 community
const mixed: MixedDashboard[] = [];
@@ -319,7 +319,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
onClick={() => onUseProvisionedDashboard(item.dashboard)}
showDatasourceProvidedBadge={true}
dimThumbnail={true}
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
kind="suggested_dashboard"
/>
);
} else {
@@ -337,7 +337,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
onClick={() => onPreviewCommunityDashboard(item.dashboard)}
isLogo={isLogo}
details={details}
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
kind="suggested_dashboard"
/>
);
}
@@ -0,0 +1,176 @@
import { css } from '@emotion/css';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { getBackendSrv, getDataSourceSrv, locationService } from '@grafana/runtime';
import { Box, Grid, Modal, Text, useStyles2 } from '@grafana/ui';
import { DASHBOARD_LIBRARY_ROUTES } from '../types';
import { DashboardCard } from './DashboardCard';
import {
CONTENT_KINDS,
CREATION_ORIGINS,
DashboardLibraryInteractions,
DISCOVERY_METHODS,
EVENT_LOCATIONS,
SourceEntryPoint,
TemplateDashboardSourceEntryPoint,
} from './interactions';
import { GnetDashboard, GnetDashboardsResponse, Link } from './types';
const SourceEntryPointMap: Record<string, SourceEntryPoint> = {
quickAdd: TemplateDashboardSourceEntryPoint.QUICK_ADD_BUTTON,
commandPalette: TemplateDashboardSourceEntryPoint.COMMAND_PALETTE,
createNewButton: TemplateDashboardSourceEntryPoint.BROWSE_DASHBOARDS_PAGE,
};
export const TemplateDashboardModal = () => {
const [searchParams, setSearchParams] = useSearchParams();
const isOpen = searchParams.get('templateDashboards') === 'true';
const entryPoint = searchParams.get('source') || '';
const testDataSource = getDataSourceSrv().getList({ type: 'grafana-testdata-datasource' })[0];
const styles = useStyles2(getStyles);
const onClose = () => {
searchParams.delete('templateDashboards');
setSearchParams(searchParams);
};
const onPreviewDashboardClick = async (dashboard: GnetDashboard) => {
const sourceEntryPoint = SourceEntryPointMap[entryPoint] || 'unknown';
DashboardLibraryInteractions.itemClicked({
contentKind: CONTENT_KINDS.TEMPLATE_DASHBOARD,
datasourceTypes: [String(testDataSource?.type)],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint,
eventLocation: EVENT_LOCATIONS.BROWSE_DASHBOARDS_PAGE,
discoveryMethod: DISCOVERY_METHODS.BROWSE,
});
const params = new URLSearchParams({
datasource: testDataSource?.uid || '',
title: dashboard.name,
pluginId: String(testDataSource?.type) || '',
gnetId: String(dashboard.id),
// tracking event purpose values
sourceEntryPoint,
libraryItemId: String(dashboard.id),
creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_TEMPLATE_DASHBOARD,
contentKind: CONTENT_KINDS.TEMPLATE_DASHBOARD,
});
const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`;
locationService.push(templateUrl);
};
const { value: dashboards = [], loading } = useAsync(async () => {
if (!isOpen) {
return [];
}
try {
const response = await getBackendSrv().get<GnetDashboardsResponse>(
`/api/gnet/dashboards?orgSlug=raintank&categorySlug=templates&includeScreenshots=true`,
undefined,
undefined,
{
showErrorAlert: false,
}
);
return response.items;
} catch (error) {
console.error('Error loading template dashboards ', error);
return [];
}
}, [isOpen]);
useEffect(() => {
if (isOpen && !loading && dashboards.length > 0) {
DashboardLibraryInteractions.loaded({
numberOfItems: dashboards.length,
contentKinds: [CONTENT_KINDS.TEMPLATE_DASHBOARD],
datasourceTypes: [String(testDataSource?.type)],
sourceEntryPoint: SourceEntryPointMap[entryPoint] || 'unknown',
eventLocation: EVENT_LOCATIONS.BROWSE_DASHBOARDS_PAGE,
});
}
}, [isOpen, dashboards, entryPoint, testDataSource?.type, loading]);
if (!testDataSource || (dashboards.length === 0 && !loading)) {
return null;
}
return (
<Modal
isOpen={isOpen}
onDismiss={onClose}
className={styles.modal}
title={t('dashboard-library.template-dashboard-modal.title', 'Start a dashboard from a template')}
contentClassName={styles.modalContent}
>
<div className={styles.stickyHeader}>
<Text element="p">
<Trans i18nKey="dashboard-library.template-dashboard-modal.description">
Get started with Grafana templates using sample data. Connect your data to power them with real metrics.
</Trans>
</Text>
</div>
<Box direction="column" gap={4} display="flex">
<Grid
gap={4}
columns={{
xs: 1,
sm: 2,
lg: 3,
}}
>
{loading
? Array.from({ length: 4 }).map((_, index) => <DashboardCard.Skeleton key={index} />)
: dashboards?.map((dashboard) => {
const thumbnail = dashboard.screenshots?.[0]?.links.find((l: Link) => l.rel === 'image')?.href ?? '';
const thumbnailUrl = thumbnail ? `/api/gnet${thumbnail}` : '';
return (
<DashboardCard
key={dashboard.id}
title={dashboard.name}
imageUrl={thumbnailUrl}
onClick={() => onPreviewDashboardClick(dashboard)}
dashboard={dashboard}
kind="template_dashboard"
/>
);
})}
</Grid>
</Box>
</Modal>
);
};
function getStyles(theme: GrafanaTheme2) {
return {
modal: css({
width: '1200px',
}),
stickyHeader: css({
position: 'sticky',
top: 0,
zIndex: 2,
backgroundColor: theme.colors.background.primary,
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(2),
}),
modalContent: css({
paddingTop: 0,
height: '100%',
}),
};
}
@@ -71,11 +71,11 @@ export async function fetchCommunityDashboards(
// Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] }
// We normalize it to use "dashboards" instead of "items" for consistency
if (result && Array.isArray(result.items)) {
if (result) {
return {
page: result.page || params.page,
pages: result.pages || 1,
dashboards: result.items,
items: result.items,
};
}
@@ -84,7 +84,7 @@ export async function fetchCommunityDashboards(
return {
page: params.page,
pages: 1,
dashboards: [],
items: [],
};
}
@@ -7,16 +7,25 @@ export const EVENT_LOCATIONS = {
EMPTY_DASHBOARD: 'empty_dashboard',
MODAL_PROVISIONED_TAB: 'suggested_dashboards_modal_provisioned_tab',
MODAL_COMMUNITY_TAB: 'suggested_dashboards_modal_community_tab',
BROWSE_DASHBOARDS_PAGE: 'browse_dashboards_page',
} as const;
export const CONTENT_KINDS = {
DATASOURCE_DASHBOARD: 'datasource_dashboard',
COMMUNITY_DASHBOARD: 'community_dashboard',
TEMPLATE_DASHBOARD: 'template_dashboard',
// in future this could also include "TEMPLATE_DASHBOARD" if/when items become templates
} as const;
export const TemplateDashboardSourceEntryPoint = {
QUICK_ADD_BUTTON: 'quick_add_button',
COMMAND_PALETTE: 'command_palette',
BROWSE_DASHBOARDS_PAGE: 'browse_dashboards_page_create_new_button',
} as const;
export const SOURCE_ENTRY_POINTS = {
DATASOURCE_PAGE: 'datasource_page',
...TemplateDashboardSourceEntryPoint,
// possible future flows: CREATE_DASHBOARD, EMPTY_STATE
} as const;
@@ -28,6 +37,7 @@ export const DISCOVERY_METHODS = {
export const CREATION_ORIGINS = {
DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD: 'dashboard_library_datasource_dashboard',
DASHBOARD_LIBRARY_COMMUNITY_DASHBOARD: 'dashboard_library_community_dashboard',
DASHBOARD_LIBRARY_TEMPLATE_DASHBOARD: 'dashboard_library_template_dashboard',
} as const;
// Derive types from constant maps for single source of truth
@@ -91,6 +101,9 @@ export const DashboardLibraryInteractions = {
}) => {
reportDashboardLibraryInteraction('mapping_form_completed', properties);
},
entryPointClicked: (properties: { entryPoint: SourceEntryPoint; contentKind: ContentKind }) => {
reportDashboardLibraryInteraction('entry_point_clicked', properties);
},
};
const reportDashboardLibraryInteraction = (name: string, properties?: Record<string, unknown>) => {
@@ -22,7 +22,6 @@ export interface Logo {
export interface GnetDashboard {
id: number;
uid: string;
name: string;
description: string;
downloads: number;
@@ -44,5 +43,5 @@ export interface GnetDashboard {
export interface GnetDashboardsResponse {
page: number;
pages: number;
dashboards: GnetDashboard[];
items: GnetDashboard[];
}
@@ -9,34 +9,6 @@ import { GnetDashboard, Link } from '../types';
import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources';
/**
* Extract datasource types from URL parameters for tracking purposes.
* Supports two formats:
* - datasourceTypes: JSON array of datasource types (for community dashboards)
* - pluginId: Single datasource type (legacy format for provisioned dashboards)
*
* @returns Array of datasource type strings, or undefined if not available
*/
export function extractDatasourceTypesFromUrl(): string[] | undefined {
const params = locationService.getSearchObject();
const datasourceTypesParam = params.datasourceTypes;
const pluginIdParam = params.pluginId;
if (datasourceTypesParam && typeof datasourceTypesParam === 'string') {
try {
return JSON.parse(datasourceTypesParam);
} catch {
// If parsing fails, return undefined
return undefined;
}
} else if (pluginIdParam && typeof pluginIdParam === 'string') {
// Fallback to legacy pluginId for provisioned dashboards
return [pluginIdParam];
}
return undefined;
}
/**
* Extract thumbnail URL from dashboard screenshots
*/
@@ -0,0 +1,71 @@
import { locationService } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils';
import { CONTENT_KINDS } from '../interactions';
function getPanelDatasourceTypes(scene: DashboardScene): string[] {
const types = new Set<string>();
const panels = scene.state.body.getVizPanels();
for (const child of panels) {
const ts = panelDatasourceTypes(child);
for (const t of ts) {
types.add(t);
}
}
return Array.from(types).sort();
}
function panelDatasourceTypes(vizPanel: VizPanel) {
const types = new Set<string>();
const queryRunner = getQueryRunnerFor(vizPanel);
if (queryRunner) {
for (const q of queryRunner.state.queries) {
types.add(q.datasource?.type ?? '');
}
}
return Array.from(types);
}
/**
* Extract datasource types from URL parameters or dashboard panels based on the content kind.
* Supports two formats:
* - datasourceTypes: JSON array of datasource types (for community dashboards)
* - pluginId: Single datasource type (legacy format for provisioned dashboards)
*
* @returns Array of datasource type strings, or undefined if not available
*/
export function getDatasourceTypes(dashboard: DashboardScene): string[] | undefined {
const params = locationService.getSearchObject();
const datasourceTypesParam = params.datasourceTypes;
const pluginIdParam = params.pluginId;
const contentKind = params.contentKind;
switch (contentKind) {
case CONTENT_KINDS.COMMUNITY_DASHBOARD: {
if (datasourceTypesParam && typeof datasourceTypesParam === 'string') {
try {
return JSON.parse(datasourceTypesParam);
} catch {
// If parsing fails, return undefined
return undefined;
}
}
return undefined;
}
case CONTENT_KINDS.DATASOURCE_DASHBOARD:
return pluginIdParam && typeof pluginIdParam === 'string' ? [pluginIdParam] : undefined;
case CONTENT_KINDS.TEMPLATE_DASHBOARD: {
const datasourceTypes = getPanelDatasourceTypes(dashboard);
return datasourceTypes.length > 0 ? datasourceTypes : undefined;
}
default:
return undefined;
}
}
@@ -2,6 +2,7 @@
// TODO: remove this when new Browse Dashboards UI is no longer feature flagged
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
export function getSearchPlaceholder(includePanels = false) {
return includePanels
@@ -10,7 +11,13 @@ export function getSearchPlaceholder(includePanels = false) {
}
export function getNewDashboardPhrase() {
return t('search.dashboard-actions.new-dashboard', 'New dashboard');
return config.featureToggles.dashboardTemplates
? t('search.dashboard-actions.empty-dashboard', 'Empty dashboard')
: t('search.dashboard-actions.new-dashboard', 'New dashboard');
}
export function getNewTemplateDashboardPhrase() {
return t('search.dashboard-actions.new-template-dashboard', 'Dashboard from template');
}
export function getNewFolderPhrase() {
+11 -2
View File
@@ -4132,6 +4132,7 @@
"action": {
"change-theme": "Change theme",
"dark-theme": "Dark",
"dashboard-from-template": "Dashboard from template",
"light-theme": "Light",
"scopes": "Scopes"
},
@@ -5718,6 +5719,10 @@
"suggested-dashboards-subtitle": "Browse and select from data-source provided or community dashboards",
"suggested-dashboards-title": "Build a dashboard using suggested options for your selected data source",
"suggested-dashboards-title-with-datasource": "Build a dashboard using suggested options for your {{datasourceType}} data source",
"template-dashboard-modal": {
"description": "Get started with Grafana templates using sample data. Connect your data to power them with real metrics.",
"title": "Start a dashboard from a template"
},
"view-all": "View all"
},
"dashboard-links": {
@@ -10528,6 +10533,7 @@
"title": "New"
},
"new-dashboard": {
"empty-title": "Empty dashboard",
"title": "New dashboard"
},
"new-folder": {
@@ -10693,7 +10699,8 @@
"aria-label": "Profile"
},
"quick-add": {
"aria-label": "New"
"aria-label": "New",
"new-template-dashboard-button": "Dashboard from template"
},
"rss-button": "Latest from the blog"
},
@@ -12427,10 +12434,12 @@
}
},
"dashboard-actions": {
"empty-dashboard": "Empty dashboard",
"import": "Import",
"new": "New",
"new-dashboard": "New dashboard",
"new-folder": "New folder"
"new-folder": "New folder",
"new-template-dashboard": "Dashboard from template"
},
"generate-columns": {
"score": "Score"