diff --git a/public/app/features/provisioning/constants.ts b/public/app/features/provisioning/constants.ts new file mode 100644 index 00000000000..9855c025144 --- /dev/null +++ b/public/app/features/provisioning/constants.ts @@ -0,0 +1,4 @@ +export const PROVISIONING_URL = '/admin/provisioning'; +export const CONNECT_URL = `${PROVISIONING_URL}/connect`; +export const MIGRATE_URL = `${PROVISIONING_URL}/migrate`; +export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`; diff --git a/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts b/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts new file mode 100644 index 00000000000..37f803a9838 --- /dev/null +++ b/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import { RepositorySpec, useCreateRepositoryMutation, useReplaceRepositoryMutation } from '../api'; + +export function useCreateOrUpdateRepository(name?: string) { + const [create, createRequest] = useCreateRepositoryMutation(); + const [update, updateRequest] = useReplaceRepositoryMutation(); + + const updateOrCreate = useCallback( + (data: RepositorySpec) => { + if (name) { + return update({ name, repository: { metadata: { name }, spec: data } }); + } + return create({ repository: { metadata: generateRepositoryMetadata(data), spec: data } }); + }, + [create, name, update] + ); + + return [updateOrCreate, name ? updateRequest : createRequest] as const; +} + +const generateRepositoryMetadata = (data: RepositorySpec) => { + // We don't know for sure that we can use a normalised name. If we can't, we'll ask the server to generate one for us. + const normalisedName = data.title.toLowerCase().replaceAll(/[^a-z0-9\-_]+/g, ''); + + if ( + crypto.randomUUID && // we might not be in a secure context + normalisedName && // we need a non-empty string before we check the first character + normalisedName.charAt(0) >= 'a' && // required to start with a letter to be a valid k8s name + normalisedName.charAt(0) <= 'z' && + normalisedName.replaceAll(/[^a-z]/g, '').length >= 3 // must look sensible to a human + ) { + // We still want a suffix, to avoid name collisions. + const randomBit = crypto.randomUUID().substring(0, 7); + const shortenedName = normalisedName.substring(0, 63 - 1 - randomBit.length); + return { name: `${shortenedName}-${randomBit}` }; + } else { + return { generateName: 'r' }; + } +}; diff --git a/public/app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile.ts b/public/app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile.ts new file mode 100644 index 00000000000..dbcc9ffb978 --- /dev/null +++ b/public/app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; + +import { + ReplaceRepositoryFilesWithPathArg, + useCreateRepositoryFilesWithPathMutation, + useReplaceRepositoryFilesWithPathMutation, +} from '../api'; + +export function useCreateOrUpdateRepositoryFile(name?: string) { + const [create, createRequest] = useCreateRepositoryFilesWithPathMutation(); + const [update, updateRequest] = useReplaceRepositoryFilesWithPathMutation(); + + const updateOrCreate = useCallback( + (data: ReplaceRepositoryFilesWithPathArg) => { + const actions = name ? update : create; + return actions(data); + }, + [create, name, update] + ); + + return [updateOrCreate, name ? updateRequest : createRequest] as const; +} diff --git a/public/app/features/provisioning/hooks/useGetResourceRepository.ts b/public/app/features/provisioning/hooks/useGetResourceRepository.ts new file mode 100644 index 00000000000..1d09d69141c --- /dev/null +++ b/public/app/features/provisioning/hooks/useGetResourceRepository.ts @@ -0,0 +1,25 @@ +import { skipToken } from '@reduxjs/toolkit/query/react'; + +import { AnnoKeyManagerKind } from '../../apiserver/types'; +import { useGetFolderQuery } from '../../folders/api'; + +import { useRepositoryList } from './useRepositoryList'; + +interface GetResourceRepositoryArgs { + name?: string; + folderUid?: string; +} + +export const useGetResourceRepository = ({ name, folderUid }: GetResourceRepositoryArgs) => { + const [items, isLoading] = useRepositoryList(name || !folderUid ? skipToken : undefined); + // Get the folder data from API to get the repository data for nested folders + const folderQuery = useGetFolderQuery(name || !folderUid ? skipToken : { name: folderUid }); + + const repoName = name || folderQuery.data?.metadata?.annotations?.[AnnoKeyManagerKind]; + + if (!items?.length || isLoading || !repoName) { + return undefined; + } + + return items.find((repo) => repo.metadata?.name === repoName); +}; diff --git a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts new file mode 100644 index 00000000000..441552a392b --- /dev/null +++ b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts @@ -0,0 +1,10 @@ +import { skipToken } from '@reduxjs/toolkit/query'; + +import { RepositoryViewList, useGetFrontendSettingsQuery } from '../api'; +import { checkSyncSettings } from '../utils/checkSyncSettings'; + +export function useIsProvisionedInstance(settings?: RepositoryViewList) { + const settingsQuery = useGetFrontendSettingsQuery(settings ? skipToken : undefined); + const [instanceConnected] = checkSyncSettings(settings || settingsQuery.data); + return instanceConnected; +} diff --git a/public/app/features/provisioning/hooks/useIsProvisionedNG.ts b/public/app/features/provisioning/hooks/useIsProvisionedNG.ts new file mode 100644 index 00000000000..74ac239ad83 --- /dev/null +++ b/public/app/features/provisioning/hooks/useIsProvisionedNG.ts @@ -0,0 +1,20 @@ +import { useUrlParams } from 'app/core/navigation/hooks'; + +import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; +import { useGetFrontendSettingsQuery } from '../api'; + +import { useGetResourceRepository } from './useGetResourceRepository'; + +export function useIsProvisionedNG(dashboard: DashboardScene): boolean { + const [params] = useUrlParams(); + const folderUid = params.get('folderUid') || undefined; + + const folderRepository = useGetResourceRepository({ folderUid }); + const { data } = useGetFrontendSettingsQuery(); + + return ( + dashboard.isManaged() || + Boolean(folderRepository) || + Boolean(data?.items.some((item) => item.target === 'instance')) + ); +} diff --git a/public/app/features/provisioning/hooks/usePullRequestParam.ts b/public/app/features/provisioning/hooks/usePullRequestParam.ts new file mode 100644 index 00000000000..2aefe258ddc --- /dev/null +++ b/public/app/features/provisioning/hooks/usePullRequestParam.ts @@ -0,0 +1,12 @@ +import { useUrlParams } from 'app/core/navigation/hooks'; + +export const usePullRequestParam = () => { + const [params] = useUrlParams(); + const prParam = params.get('pull_request_url'); + + if (!prParam) { + return undefined; + } + + return decodeURIComponent(prParam); +}; diff --git a/public/app/features/provisioning/hooks/useRepositoryJobs.ts b/public/app/features/provisioning/hooks/useRepositoryJobs.ts new file mode 100644 index 00000000000..1935cf167ef --- /dev/null +++ b/public/app/features/provisioning/hooks/useRepositoryJobs.ts @@ -0,0 +1,32 @@ +import { skipToken } from '@reduxjs/toolkit/query/react'; + +import { Job, useListJobQuery } from '../api'; + +interface RepositoryJobsArgs { + name?: string; + watch?: boolean; +} + +export function useRepositoryJobs({ name, watch = true }: RepositoryJobsArgs = {}): [ + Job[] | undefined, + ReturnType, +] { + const query = useListJobQuery( + name + ? { + labelSelector: `repository=${name}`, + watch, + } + : skipToken + ); + + const collator = new Intl.Collator(undefined, { numeric: true }); + + const sortedItems = query.data?.items?.slice().sort((a, b) => { + const aTime = a.metadata?.creationTimestamp ?? ''; + const bTime = b.metadata?.creationTimestamp ?? ''; + return collator.compare(bTime, aTime); // Reverse order for newest first + }); + + return [sortedItems, query]; +} diff --git a/public/app/features/provisioning/hooks/useRepositoryList.ts b/public/app/features/provisioning/hooks/useRepositoryList.ts new file mode 100644 index 00000000000..fe68c11ae43 --- /dev/null +++ b/public/app/features/provisioning/hooks/useRepositoryList.ts @@ -0,0 +1,19 @@ +import { skipToken } from '@reduxjs/toolkit/query'; + +import { ListRepositoryArg, Repository, useListRepositoryQuery } from '../api'; + +// Sort repositories alphabetically by title +export function useRepositoryList( + options: ListRepositoryArg | typeof skipToken = {} +): [Repository[] | undefined, boolean] { + const query = useListRepositoryQuery(options); + const collator = new Intl.Collator(undefined, { numeric: true }); + + const sortedItems = query.data?.items?.slice().sort((a, b) => { + const titleA = a.spec?.title ?? ''; + const titleB = b.spec?.title ?? ''; + return collator.compare(titleA, titleB); + }); + + return [sortedItems, query.isLoading]; +} diff --git a/public/app/features/provisioning/types.ts b/public/app/features/provisioning/types.ts new file mode 100644 index 00000000000..2fffca4e522 --- /dev/null +++ b/public/app/features/provisioning/types.ts @@ -0,0 +1,14 @@ +import { GitHubRepositoryConfig, LocalRepositoryConfig, RepositorySpec } from './api'; + +export type RepositoryFormData = Omit & + GitHubRepositoryConfig & + LocalRepositoryConfig; + +// Added to DashboardDTO to help editor +export interface ProvisioningPreview { + repo: string; + file: string; + ref?: string; +} + +export type WorkflowOption = 'branch' | 'write'; diff --git a/public/app/features/provisioning/utils/checkSyncSettings.ts b/public/app/features/provisioning/utils/checkSyncSettings.ts new file mode 100644 index 00000000000..a69eee3f9d5 --- /dev/null +++ b/public/app/features/provisioning/utils/checkSyncSettings.ts @@ -0,0 +1,10 @@ +import { RepositoryViewList } from '../api'; + +export function checkSyncSettings(settings?: RepositoryViewList): [boolean, boolean] { + if (!settings?.items?.length) { + return [false, false]; + } + const instanceConnected = settings.items.some((item) => item.target === 'instance'); + const folderConnected = settings.items.some((item) => item.target === 'folder'); + return [instanceConnected, folderConnected]; +} diff --git a/public/app/features/provisioning/utils/data.ts b/public/app/features/provisioning/utils/data.ts new file mode 100644 index 00000000000..bf83b161eca --- /dev/null +++ b/public/app/features/provisioning/utils/data.ts @@ -0,0 +1,38 @@ +import { RepositorySpec } from '../api'; +import { RepositoryFormData } from '../types'; + +export const dataToSpec = (data: RepositoryFormData): RepositorySpec => { + const spec: RepositorySpec = { + type: data.type, + sync: data.sync, + title: data.title || '', + workflows: data.workflows, + }; + switch (data.type) { + case 'github': + spec.github = { + generateDashboardPreviews: data.generateDashboardPreviews, + url: data.url || '', + branch: data.branch, + token: data.token, + }; + break; + case 'local': + spec.local = { + path: data.path, + }; + break; + } + + return spec; +}; + +export const specToData = (spec: RepositorySpec): RepositoryFormData => { + return { + ...spec, + ...spec.github, + ...spec.local, + branch: spec.github?.branch || '', + url: spec.github?.url || '', + }; +}; diff --git a/public/app/features/provisioning/utils/git.ts b/public/app/features/provisioning/utils/git.ts new file mode 100644 index 00000000000..c6e8ebb4f62 --- /dev/null +++ b/public/app/features/provisioning/utils/git.ts @@ -0,0 +1,14 @@ +/** + * Validates a Git branch name according to the following rules: + * 1. The branch name cannot start with `/`, end with `/`, `.`, or whitespace. + * 2. The branch name cannot contain consecutive slashes (`//`). + * 3. The branch name cannot contain consecutive dots (`..`). + * 4. The branch name cannot contain `@{`. + * 5. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`. + * 6. The branch name must have at least one character and must not be empty. + */ +export function validateBranchName(branchName?: string) { + const branchNameRegex = /^(?!\/|.*\/\/|.*\.\.|.*@{)(?!.*[~^:?*[\]\\]).+(?