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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user