diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index b0dabcaf881..9ce7a72684a 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -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)), diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx index 3d38c543105..94603269de4 100644 --- a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx @@ -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?.(); + }} /> ))} diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 0fab359de4e..0ac5619e893 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -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 // 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': diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index bc9572efdbd..921c67ba9cb 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -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 + {config.featureToggles.dashboardTemplates && } ); diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index c71b67a911f..c263b110e56 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -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 = ( {canCreateDashboard && ( - - reportInteraction('grafana_menu_item_clicked', { - url: buildUrl('/dashboard/new', parentFolder?.uid), - from: location.pathname, - }) - } - url={buildUrl('/dashboard/new', parentFolder?.uid)} - /> + <> + + reportInteraction('grafana_menu_item_clicked', { + url: buildUrl('/dashboard/new', parentFolder?.uid), + from: location.pathname, + }) + } + url={buildUrl('/dashboard/new', parentFolder?.uid)} + /> + {renderPreBuiltDashboardAction && ( + + DashboardLibraryInteractions.entryPointClicked({ + entryPoint: SOURCE_ENTRY_POINTS.BROWSE_DASHBOARDS_PAGE, + contentKind: CONTENT_KINDS.TEMPLATE_DASHBOARD, + }) + } + url={buildUrl('/dashboards?templateDashboards=true&source=createNewButton', parentFolder?.uid)} + /> + )} + )} {canCreateFolder && setShowNewFolderDrawer(true)} label={getNewFolderPhrase()} />} {canCreateDashboard && !isProvisionedInstance && parentFolder?.managedBy !== ManagerKind.Repo && ( diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index 597e749a6cc..e76938b8c6f 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -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({ diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index e7c854fc815..be05bb63c66 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -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 { + private async loadDashboardLibrary(): Promise { // 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 { + // 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 { // 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); diff --git a/public/app/features/dashboard-scene/utils/tracking.ts b/public/app/features/dashboard-scene/utils/tracking.ts index 0be4cdd05ed..20b2123fc8c 100644 --- a/public/app/features/dashboard-scene/utils/tracking.ts +++ b/public/app/features/dashboard-scene/utils/tracking.ts @@ -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, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx index 2c70c354fbe..d914a31f1bc 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx @@ -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 ( onPreviewCommunityDashboard(dashboard)} isLogo={isLogo} details={details} - buttonText={Use dashboard} + 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), - }), }; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx index 8d038e0e5c3..0f308b0a4ad 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx @@ -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({ {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, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx index a672d3eb04f..fefd0f872b2 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx @@ -157,7 +157,7 @@ export const DashboardLibrarySection = () => { imageUrl={imageUrl} dashboard={dashboard} onClick={() => onUseProvisionedDashboard(dashboard)} - buttonText={Use dashboard} + kind="suggested_dashboard" /> ); }) || []} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx index 8e0389525b0..d0384e9746d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx @@ -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={Use dashboard} + kind="suggested_dashboard" /> ); } else { @@ -337,7 +337,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { onClick={() => onPreviewCommunityDashboard(item.dashboard)} isLogo={isLogo} details={details} - buttonText={Use dashboard} + kind="suggested_dashboard" /> ); } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal.tsx new file mode 100644 index 00000000000..57dbeafceea --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal.tsx @@ -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 = { + 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( + `/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 ( + +
+ + + Get started with Grafana templates using sample data. Connect your data to power them with real metrics. + + +
+ + + {loading + ? Array.from({ length: 4 }).map((_, index) => ) + : dashboards?.map((dashboard) => { + const thumbnail = dashboard.screenshots?.[0]?.links.find((l: Link) => l.rel === 'image')?.href ?? ''; + const thumbnailUrl = thumbnail ? `/api/gnet${thumbnail}` : ''; + + return ( + onPreviewDashboardClick(dashboard)} + dashboard={dashboard} + kind="template_dashboard" + /> + ); + })} + + +
+ ); +}; + +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%', + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts index b97568a8ab9..6f3a4045368 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts @@ -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: [], }; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts index 714e53145e9..804ef885d61 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts @@ -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) => { diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts index 38267a4870f..784e4f2d924 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts @@ -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[]; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts index fed08c7ae73..44c579d27b5 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -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 */ diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/dashboardLibraryHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/dashboardLibraryHelpers.ts new file mode 100644 index 00000000000..c94cf4ee5b2 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/dashboardLibraryHelpers.ts @@ -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(); + + 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(); + + 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; + } +} diff --git a/public/app/features/search/tempI18nPhrases.ts b/public/app/features/search/tempI18nPhrases.ts index 0c3206d0a50..73766e4bff6 100644 --- a/public/app/features/search/tempI18nPhrases.ts +++ b/public/app/features/search/tempI18nPhrases.ts @@ -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() { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 27e47141aad..6db79433e88 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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"