Provisioning: Add hooks and utils (#101999)

* Provisioning: Add hooks and utils

* Fix folder API call

* Update public/app/features/provisioning/utils/git.ts

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>

* Fixes

* Remove unused import

---------

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
pull/101980/head
Alex Khomenko 2 months ago committed by GitHub
parent abe6a3121c
commit dacde69ffb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      public/app/features/provisioning/constants.ts
  2. 40
      public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
  3. 22
      public/app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile.ts
  4. 25
      public/app/features/provisioning/hooks/useGetResourceRepository.ts
  5. 10
      public/app/features/provisioning/hooks/useIsProvisionedInstance.ts
  6. 20
      public/app/features/provisioning/hooks/useIsProvisionedNG.ts
  7. 12
      public/app/features/provisioning/hooks/usePullRequestParam.ts
  8. 32
      public/app/features/provisioning/hooks/useRepositoryJobs.ts
  9. 19
      public/app/features/provisioning/hooks/useRepositoryList.ts
  10. 14
      public/app/features/provisioning/types.ts
  11. 10
      public/app/features/provisioning/utils/checkSyncSettings.ts
  12. 38
      public/app/features/provisioning/utils/data.ts
  13. 14
      public/app/features/provisioning/utils/git.ts
  14. 6
      public/app/features/provisioning/utils/time.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`;

@ -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' };
}
};

@ -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;
}

@ -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);
};

@ -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;
}

@ -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'))
);
}

@ -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);
};

@ -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<typeof useListJobQuery>,
] {
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];
}

@ -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];
}

@ -0,0 +1,14 @@
import { GitHubRepositoryConfig, LocalRepositoryConfig, RepositorySpec } from './api';
export type RepositoryFormData = Omit<RepositorySpec, 'github' | 'local'> &
GitHubRepositoryConfig &
LocalRepositoryConfig;
// Added to DashboardDTO to help editor
export interface ProvisioningPreview {
repo: string;
file: string;
ref?: string;
}
export type WorkflowOption = 'branch' | 'write';

@ -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];
}

@ -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 || '',
};
};

@ -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 = /^(?!\/|.*\/\/|.*\.\.|.*@{)(?!.*[~^:?*[\]\\]).+(?<!\/|\.|\s)$/;
return branchName && branchNameRegex.test(branchName!);
}

@ -0,0 +1,6 @@
export function formatTimestamp(timestamp?: number) {
if (!timestamp) {
return 'N/A';
}
return new Date(timestamp).toLocaleString();
}
Loading…
Cancel
Save