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>
This commit is contained in:
co-authored by
Ryan McKinley
parent
abe6a3121c
commit
dacde69ffb
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user