From 646fb2aa35dfb13d033087c6baf1538fa861c735 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 28 Nov 2025 07:47:59 +0200 Subject: [PATCH] Provisioning: Add source link to provisioned dashboards (#114552) * Provisioning: Add dashboard source link * Fix type * Refactor * Simplify code * more fixes * Extract utils * Switch to object params * Fix types * Move to existing file --- public/app/features/dashboard/api/v1.ts | 23 ++++-- public/app/features/dashboard/api/v2.ts | 7 ++ .../Repository/ResourceTreeView.tsx | 15 +++- .../Shared/PreviewBannerViewPR.test.tsx | 4 +- .../components/Shared/PreviewBannerViewPR.tsx | 10 +-- public/app/features/provisioning/guards.ts | 9 +++ public/app/features/provisioning/utils/git.ts | 57 +++++++------- .../features/provisioning/utils/sourceLink.ts | 75 +++++++++++++++++++ public/locales/en-US/grafana.json | 4 + 9 files changed, 159 insertions(+), 45 deletions(-) create mode 100644 public/app/features/provisioning/utils/sourceLink.ts diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index b8c57653923..9414d3531a6 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -6,21 +6,22 @@ import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks'; import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors'; import { ScopedResourceClient } from 'app/features/apiserver/client'; import { - ResourceClient, - ResourceForCreate, - AnnoKeyMessage, AnnoKeyFolder, AnnoKeyGrantPermissions, - Resource, - DeprecatedInternalId, - AnnoKeyManagerKind, - AnnoKeySourcePath, AnnoKeyManagerAllowsEdits, - ManagerKind, + AnnoKeyManagerKind, + AnnoKeyMessage, + AnnoKeySourcePath, AnnoReloadOnParamsChange, + DeprecatedInternalId, + ManagerKind, + Resource, + ResourceClient, + ResourceForCreate, } from 'app/features/apiserver/types'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { buildSourceLink } from 'app/features/provisioning/utils/sourceLink'; import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; @@ -160,6 +161,12 @@ export class K8sDashboardAPI implements DashboardAPI { result.meta.provisionedExternalId = annotations[AnnoKeySourcePath]; } + // Inject source link for repo-managed dashboards + const sourceLink = await buildSourceLink(annotations); + if (sourceLink) { + result.dashboard.links = [sourceLink, ...(result.dashboard.links || [])]; + } + if (dash.metadata.labels?.[DeprecatedInternalId]) { result.dashboard.id = parseInt(dash.metadata.labels[DeprecatedInternalId], 10); } diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index 33ec2323786..ddf85da7338 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -18,6 +18,7 @@ import { } from 'app/features/apiserver/types'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { buildSourceLink } from 'app/features/provisioning/utils/sourceLink'; import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; @@ -75,6 +76,12 @@ export class K8sDashboardV2API dashboard.metadata.annotations[AnnoKeyFolder] = ''; } + // Inject source link for repo-managed dashboards + const sourceLink = await buildSourceLink(dashboard.metadata.annotations); + if (sourceLink) { + dashboard.spec.links = [sourceLink, ...(dashboard.spec.links || [])]; + } + return dashboard; } catch (e) { const status = getStatusFromError(e); diff --git a/public/app/features/provisioning/Repository/ResourceTreeView.tsx b/public/app/features/provisioning/Repository/ResourceTreeView.tsx index 58282803841..33471a77af6 100644 --- a/public/app/features/provisioning/Repository/ResourceTreeView.tsx +++ b/public/app/features/provisioning/Repository/ResourceTreeView.tsx @@ -140,7 +140,20 @@ export function ResourceTreeView({ repo }: ResourceTreeViewProps) { } const viewLink = getGrafanaLink(item); - const sourceLink = item.hasFile ? getRepoFileUrl(repo.spec, item.path) : undefined; + let sourceLink: string | undefined = undefined; + if (item.hasFile && repo.spec?.type) { + const spec = repo.spec; + const config = spec.github || spec.gitlab || spec.bitbucket; + if (config) { + sourceLink = getRepoFileUrl({ + repoType: spec.type, + url: config.url, + branch: config.branch, + filePath: item.path, + pathPrefix: config.path, + }); + } + } if (!viewLink && !sourceLink) { return null; diff --git a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx index b742368dbce..2b854ad92d5 100644 --- a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx +++ b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx @@ -5,7 +5,9 @@ import { textUtil } from '@grafana/data'; import { RepoType } from 'app/features/provisioning/Wizard/types'; import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam'; -import { isValidRepoType, PreviewBannerViewPR } from './PreviewBannerViewPR'; +import { isValidRepoType } from '../../guards'; + +import { PreviewBannerViewPR } from './PreviewBannerViewPR'; jest.mock('@grafana/data', () => ({ ...jest.requireActual('@grafana/data'), diff --git a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx index 15b814a14db..932f7152dac 100644 --- a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx +++ b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx @@ -1,7 +1,8 @@ import { textUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { Alert, Box, Icon, Stack, TextLink } from '@grafana/ui'; -import { RepoTypeDisplay, RepoType } from 'app/features/provisioning/Wizard/types'; +import { RepoTypeDisplay } from 'app/features/provisioning/Wizard/types'; +import { isValidRepoType } from 'app/features/provisioning/guards'; import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam'; import { commonAlertProps } from '../Dashboards/DashboardPreviewBanner'; @@ -121,13 +122,6 @@ export function PreviewBannerViewPR({ prParam, isNewPr, behindBranch, repoUrl, b ); } -export function isValidRepoType(repoType: string | undefined): repoType is RepoType { - if (typeof repoType !== 'string') { - return false; - } - return repoType in RepoTypeDisplay; -} - function showBranchInfo(repoType: string | undefined, branchInfo?: PreviewBranchInfo): boolean { const { targetBranch, configuredBranch, repoBaseUrl } = branchInfo || {}; return repoType !== 'local' && !!targetBranch && !!configuredBranch && !!repoBaseUrl; diff --git a/public/app/features/provisioning/guards.ts b/public/app/features/provisioning/guards.ts index 1ac4e9cfdae..c9a6f16cbe2 100644 --- a/public/app/features/provisioning/guards.ts +++ b/public/app/features/provisioning/guards.ts @@ -1,3 +1,5 @@ +import { RepoType, RepoTypeDisplay } from './Wizard/types'; + export interface HttpError extends Error { status?: number; } @@ -9,3 +11,10 @@ export function isSupportedGitProvider(provider: string): provider is 'github' | export function isHttpError(err: unknown): err is HttpError { return err instanceof Error && 'status' in err; } + +export function isValidRepoType(repoType: string | undefined): repoType is RepoType { + if (typeof repoType !== 'string') { + return false; + } + return repoType in RepoTypeDisplay; +} diff --git a/public/app/features/provisioning/utils/git.ts b/public/app/features/provisioning/utils/git.ts index 4fec609c388..ac16e715a70 100644 --- a/public/app/features/provisioning/utils/git.ts +++ b/public/app/features/provisioning/utils/git.ts @@ -101,51 +101,54 @@ export function getHasTokenInstructions(type: RepoType): type is InstructionAvai return type === 'github' || type === 'gitlab' || type === 'bitbucket'; } -export function getRepoFileUrl(spec?: RepositorySpec, filePath?: string) { - if (!spec || !spec.type || !filePath) { +type GetRepoFileUrlParams = { + repoType: RepoType; + url: string | undefined; + branch?: string | undefined; + filePath: string | undefined; + pathPrefix?: string | null; +}; + +/** + * Build a URL to a specific source file in a repository. + * Only works for git providers (GitHub, GitLab, Bitbucket). + */ +export function getRepoFileUrl({ + repoType, + url, + branch, + filePath, + pathPrefix, +}: GetRepoFileUrlParams): string | undefined { + if (!url || !filePath) { return undefined; } - switch (spec.type) { - case 'github': { - const { url, branch, path } = spec.github ?? {}; - if (!url) { - return undefined; - } - const fullPath = path ? `${path}${filePath}` : filePath; + const effectiveBranch = branch || 'main'; + const fullPath = pathPrefix ? `${pathPrefix}${filePath}` : filePath; + + switch (repoType) { + case 'github': return buildRepoUrl({ baseUrl: url, - branch: branch || 'main', + branch: effectiveBranch, providerSegments: ['blob'], path: fullPath, }); - } - case 'gitlab': { - const { url, branch, path } = spec.gitlab ?? {}; - if (!url) { - return undefined; - } - const fullPath = path ? `${path}${filePath}` : filePath; + case 'gitlab': return buildRepoUrl({ baseUrl: url, - branch: branch || 'main', + branch: effectiveBranch, providerSegments: ['-', 'blob'], path: fullPath, }); - } - case 'bitbucket': { - const { url, branch, path } = spec.bitbucket ?? {}; - if (!url) { - return undefined; - } - const fullPath = path ? `${path}${filePath}` : filePath; + case 'bitbucket': return buildRepoUrl({ baseUrl: url, - branch: branch || 'main', + branch: effectiveBranch, providerSegments: ['src'], path: fullPath, }); - } default: return undefined; } diff --git a/public/app/features/provisioning/utils/sourceLink.ts b/public/app/features/provisioning/utils/sourceLink.ts new file mode 100644 index 00000000000..6018f463eab --- /dev/null +++ b/public/app/features/provisioning/utils/sourceLink.ts @@ -0,0 +1,75 @@ +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { DashboardLink } from '@grafana/schema'; +import { provisioningAPIv0alpha1, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import { + AnnoKeyManagerIdentity, + AnnoKeyManagerKind, + AnnoKeySourcePath, + ManagerKind, + ObjectMeta, +} from 'app/features/apiserver/types'; +import { dispatch } from 'app/store/store'; + +import { RepoTypeDisplay } from '../Wizard/types'; +import { isValidRepoType } from '../guards'; + +import { getHasTokenInstructions, getRepoFileUrl } from './git'; + +/** + * Build a source link for a repo-managed dashboard. + * Returns undefined if the dashboard is not repo-managed or if the repository is not a git provider. + */ +export async function buildSourceLink(annotations: ObjectMeta['annotations']): Promise { + if (!annotations || !config.featureToggles.provisioning || annotations[AnnoKeyManagerKind] !== ManagerKind.Repo) { + return undefined; + } + + const managerIdentity = annotations[AnnoKeyManagerIdentity]; + const sourcePath = annotations[AnnoKeySourcePath]; + if (!managerIdentity || !sourcePath) { + return undefined; + } + + try { + const settingsResult = await dispatch(provisioningAPIv0alpha1.endpoints.getFrontendSettings.initiate()); + const repository = settingsResult.data?.items.find((repo: RepositoryView) => repo.name === managerIdentity); + + if (!repository) { + return undefined; + } + + const repoType = repository.type; + if (!getHasTokenInstructions(repoType) || !isValidRepoType(repoType)) { + return undefined; + } + + const sourceUrl = getRepoFileUrl({ + repoType, + url: repository.url, + branch: repository.branch, + filePath: sourcePath, + pathPrefix: repository.path, + }); + if (!sourceUrl) { + return undefined; + } + + const providerName = RepoTypeDisplay[repoType]; + return { + title: t('dashboard.source-link.title', 'Source ({{provider}})', { provider: providerName }), + type: 'link', + url: sourceUrl, + icon: 'external link', + tooltip: t('dashboard.source-link.tooltip', 'View source file in repository'), + targetBlank: true, + tags: [], + asDropdown: false, + includeVars: false, + keepTime: false, + }; + } catch (e) { + console.warn('Failed to fetch repository info for source link:', e); + return undefined; + } +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a8ed1bdf15c..f2d2366b9cd 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5395,6 +5395,10 @@ "loading-initializing-dashboard": "Loading & initializing dashboard", "title-not-found": "Panel with id {{panelId}} not found" }, + "source-link": { + "title": "Source ({{provider}})", + "tooltip": "View source file in repository" + }, "sub-menu-un-connected": { "aria-label-template-variables": "Template variables" },