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
This commit is contained in:
Alex Khomenko
2025-11-28 07:47:59 +02:00
committed by GitHub
parent 34e3c20250
commit 646fb2aa35
9 changed files with 159 additions and 45 deletions
+15 -8
View File
@@ -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<DashboardDTO, Dashboard> {
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);
}
+7
View File
@@ -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);
@@ -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;
@@ -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'),
@@ -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;
@@ -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;
}
+30 -27
View File
@@ -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;
}
@@ -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<DashboardLink | undefined> {
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;
}
}
+4
View File
@@ -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"
},