Provisioning: Add config page (#96550)
* Add baseAPI * Restore schemaFile * Generate endpoints * Cleanup endpoints * Update endpoints * Set up config page * Add hook * Rename to provisioning * Register reducer and middleware * Separate List and New pages * Add GitHub config * Save data * Fix types * Display list * Fix redirect * Update tags * Common folder * Cleanup * Update public/app/routes/routes.tsx Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Add token permissions instructions * Add EditPage * load data for edit * Update hook * Show api errors * Add title and token * Use test name * Update token field state * Add delete button * Add feedback for delete action * Sort list * Do not load items for new page * Prettier * Disable type change for edit * Add title field * Update pkg/services/navtree/navtreeimpl/admin.go Co-authored-by: Ryan McKinley <ryantxu@gmail.com> * Update public/app/features/provisioning/ConfigForm.tsx Co-authored-by: Ryan McKinley <ryantxu@gmail.com> --------- Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
22 files changed
+677
-33
No files matched your search
@@ -3940,6 +3940,35 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/provisioning/ConfigForm.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
|
||||
],
|
||||
"public/app/features/provisioning/DeleteRepositoryButton.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/provisioning/EditRepositoryPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/provisioning/RepositoryListPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/provisioning/api/index.ts:5381": [
|
||||
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
|
||||
],
|
||||
"public/app/features/query/components/QueryEditorRow.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
|
||||
@@ -177,12 +177,8 @@
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
@@ -210,12 +206,8 @@
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": [
|
||||
"All"
|
||||
],
|
||||
"value": [
|
||||
"$__all"
|
||||
]
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
@@ -243,14 +235,8 @@
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": [
|
||||
"AAA",
|
||||
"ACB"
|
||||
],
|
||||
"value": [
|
||||
"AAA",
|
||||
"ACB"
|
||||
]
|
||||
"text": ["AAA", "ACB"],
|
||||
"value": ["AAA", "ACB"]
|
||||
},
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
@@ -301,17 +287,7 @@
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
]
|
||||
"refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
|
||||
},
|
||||
"timezone": "",
|
||||
"title": "Text options",
|
||||
|
||||
@@ -33,4 +33,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,14 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
|
||||
})
|
||||
}
|
||||
|
||||
// TODO Add feature toggle / permissions check
|
||||
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
|
||||
Text: "Provisioning",
|
||||
Id: "provisioning",
|
||||
SubTitle: "Manage resources from remote repositories",
|
||||
Url: s.cfg.AppSubURL + "/admin/provisioning",
|
||||
})
|
||||
|
||||
generalNode := &navtree.NavLink{
|
||||
Text: "General",
|
||||
SubTitle: "Manage default preferences and settings across Grafana",
|
||||
|
||||
@@ -29,6 +29,7 @@ import templatingReducers from 'app/features/variables/state/keyedVariablesReduc
|
||||
|
||||
import { alertingApi } from '../../features/alerting/unified/api/alertingApi';
|
||||
import { userPreferencesAPI } from '../../features/preferences/api';
|
||||
import { provisioningAPI } from '../../features/provisioning/api';
|
||||
import { queryLibraryApi } from '../../features/query-library/api/factory';
|
||||
import { cleanUpAction } from '../actions/cleanUp';
|
||||
|
||||
@@ -61,6 +62,7 @@ const rootReducers = {
|
||||
[cloudMigrationAPI.reducerPath]: cloudMigrationAPI.reducer,
|
||||
[queryLibraryApi.reducerPath]: queryLibraryApi.reducer,
|
||||
[userPreferencesAPI.reducerPath]: userPreferencesAPI.reducer,
|
||||
[provisioningAPI.reducerPath]: provisioningAPI.reducer,
|
||||
};
|
||||
|
||||
const addedReducers = {};
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Field, Combobox, SecretInput, Input, Button, Switch, TextLink, ControlledCollapse } from '@grafana/ui';
|
||||
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
|
||||
import { RepositoryResource } from './api/types';
|
||||
import { useCreateOrUpdateRepository } from './hooks';
|
||||
import { RepositoryFormData } from './types';
|
||||
import { dataToSpec, specToData } from './utils/data';
|
||||
|
||||
const typeOptions = ['GitHub', 'Local', 'S3'].map((label) => ({ label, value: label.toLowerCase() }));
|
||||
const appEvents = getAppEvents();
|
||||
|
||||
export interface ConfigFormProps {
|
||||
data?: RepositoryResource;
|
||||
}
|
||||
export function ConfigForm({ data }: ConfigFormProps) {
|
||||
const [submitData, request] = useCreateOrUpdateRepository(data?.metadata.name);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors, isDirty },
|
||||
setValue,
|
||||
watch,
|
||||
getValues,
|
||||
} = useForm<RepositoryFormData>({ defaultValues: data ? specToData(data.spec) : { type: 'github' } });
|
||||
const [tokenConfigured, setTokenConfigured] = useState(Boolean(data?.metadata?.name));
|
||||
const navigate = useNavigate();
|
||||
const watchType = watch('type');
|
||||
|
||||
useEffect(() => {
|
||||
if (request.isSuccess) {
|
||||
const formData = getValues();
|
||||
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: ['Repository settings saved'],
|
||||
});
|
||||
reset(formData);
|
||||
setTimeout(() => {
|
||||
navigate('/admin/provisioning');
|
||||
}, 300);
|
||||
}
|
||||
}, [request.isSuccess, reset, getValues, navigate]);
|
||||
|
||||
const onSubmit = (data: RepositoryFormData) => {
|
||||
const spec = dataToSpec(data);
|
||||
submitData(spec);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: 700 }}>
|
||||
<FormPrompt onDiscard={reset} confirmRedirect={isDirty} />
|
||||
<Field label={'Repository type'}>
|
||||
<Controller
|
||||
name={'type'}
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Combobox
|
||||
options={typeOptions}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
placeholder={'Select repository type'}
|
||||
disabled={!!data?.spec}
|
||||
{...field}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={'Title'}
|
||||
description={'A human-readable name for the config'}
|
||||
invalid={!!errors.title}
|
||||
error={errors?.title?.message}
|
||||
>
|
||||
<Input {...register('title', { required: 'This field is required.' })} placeholder={'My config'} />
|
||||
</Field>
|
||||
{watchType === 'github' && (
|
||||
<>
|
||||
<ControlledCollapse collapsible label="Access Token Permissions" isOpen>
|
||||
<p>
|
||||
To create a new Access Token, navigate to{' '}
|
||||
<TextLink external href="https://github.com/settings/tokens">
|
||||
Personal Access Tokens
|
||||
</TextLink>{' '}
|
||||
and create a click "Generate new token."
|
||||
</p>
|
||||
|
||||
<p>Ensure that your token has the following permissions:</p>
|
||||
|
||||
<b>For all repositories:</b>
|
||||
<pre>public_repo, repo:status, repo_deployment, read:packages, read:user, user:email</pre>
|
||||
|
||||
<b>For GitHub projects:</b>
|
||||
<pre>read:org, read:project</pre>
|
||||
|
||||
<b>An extra setting is required for private repositories:</b>
|
||||
<pre>repo (Full control of private repositories)</pre>
|
||||
</ControlledCollapse>
|
||||
<Field label={'GitHub token'} required error={errors?.token?.message} invalid={!!errors.token}>
|
||||
<Controller
|
||||
name={'token'}
|
||||
control={control}
|
||||
rules={{ required: 'This field is required.' }}
|
||||
render={({ field: { ref, ...field } }) => {
|
||||
return (
|
||||
<SecretInput
|
||||
{...field}
|
||||
id={'token'}
|
||||
placeholder={'ghp_yourTokenHere1234567890abcdEFGHijklMNOP'}
|
||||
isConfigured={tokenConfigured}
|
||||
onReset={() => {
|
||||
setValue('token', '');
|
||||
setTokenConfigured(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={'Repository owner'} error={errors?.owner?.message} invalid={!!errors?.owner}>
|
||||
<Input {...register('owner', { required: 'This field is required.' })} placeholder={'test'} />
|
||||
</Field>
|
||||
<Field label={'Repository name'} error={errors?.repository?.message} invalid={!!errors?.repository}>
|
||||
<Input {...register('repository', { required: 'This field is required.' })} placeholder={'example'} />
|
||||
</Field>
|
||||
<Field label={'Commit directly to main'}>
|
||||
<Switch {...register('branchWorkflow')} id={'branchWorkflow'} />
|
||||
</Field>
|
||||
<Field label={'Show dashboard previews'}>
|
||||
<Switch {...register('generateDashboardPreviews')} id={'generateDashboardPreviews'} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchType === 'local' && (
|
||||
<Field label={'Local path'} error={errors?.path?.message} invalid={!!errors?.path}>
|
||||
<Input {...register('path', { required: 'This field is required.' })} placeholder={'/path/to/repo'} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{watchType === 's3' && (
|
||||
<>
|
||||
<Field label={'S3 bucket'} error={errors?.bucket?.message} invalid={!!errors?.bucket}>
|
||||
<Input {...register('bucket', { required: 'This field is required.' })} placeholder={'bucket-name'} />
|
||||
</Field>
|
||||
<Field label={'S3 region'} error={errors?.region?.message} invalid={!!errors?.region}>
|
||||
<Input {...register('region', { required: 'This field is required.' })} placeholder={'us-west-2'} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label={'Target folder'}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'folder'}
|
||||
render={({ field: { ref, ...field } }) => {
|
||||
return <FolderPicker {...field} />;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Button type={'submit'}>Save</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Button, ConfirmModal } from '@grafana/ui';
|
||||
|
||||
import { useDeleteRepositoryMutation } from './api';
|
||||
|
||||
const appEvents = getAppEvents();
|
||||
export function DeleteRepositoryButton({ name }: { name: string }) {
|
||||
const [deleteRepository, request] = useDeleteRepositoryMutation();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (request.isSuccess) {
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: ['Repository settings deleted'],
|
||||
});
|
||||
}
|
||||
}, [request.isSuccess]);
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
deleteRepository({ name });
|
||||
}, [deleteRepository, name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<ConfirmModal
|
||||
isOpen={showModal}
|
||||
title={'Delete repository config'}
|
||||
body={'Are you sure you want to delete the repository config?'}
|
||||
confirmText={'Delete'}
|
||||
onConfirm={onConfirm}
|
||||
onDismiss={() => setShowModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { EmptyState, Text, TextLink } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { ConfigForm } from './ConfigForm';
|
||||
import { useGetRepositoryQuery } from './api';
|
||||
import { PROVISIONING_URL } from './constants';
|
||||
|
||||
export default function EditRepositoryPage() {
|
||||
const { name = '' } = useParams();
|
||||
const query = useGetRepositoryQuery({ name });
|
||||
//@ts-expect-error TODO add error types
|
||||
const notFound = query.isError && query.error?.status === 404;
|
||||
return (
|
||||
<Page
|
||||
navId="provisioning"
|
||||
pageNav={{ text: 'Configure repository', subTitle: 'Configure a repository for storing your resources.' }}
|
||||
>
|
||||
<Page.Contents isLoading={query.isLoading}>
|
||||
{notFound ? (
|
||||
<EmptyState message={`Repository config not found`} variant="not-found">
|
||||
<Text element={'p'}>Make sure the repository config exists in the configuration file.</Text>
|
||||
<TextLink href={PROVISIONING_URL}>Back to repositories</TextLink>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ConfigForm data={query.data} />
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { ConfigForm } from './ConfigForm';
|
||||
|
||||
export default function NewRepositoryPage() {
|
||||
return (
|
||||
<Page
|
||||
navId="provisioning"
|
||||
pageNav={{ text: 'Configure repository', subTitle: 'Configure a repository for storing your resources.' }}
|
||||
>
|
||||
<Page.Contents>
|
||||
<ConfigForm />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Card, EmptySearchResult, EmptyState, FilterInput, LinkButton, Stack } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { Resource } from '../apiserver/types';
|
||||
|
||||
import { DeleteRepositoryButton } from './DeleteRepositoryButton';
|
||||
import { RepositorySpec } from './api/types';
|
||||
import { NEW_URL, PROVISIONING_URL } from './constants';
|
||||
import { useRepositoryList } from './hooks';
|
||||
|
||||
export default function RepositoryListPage() {
|
||||
const [items, isLoading] = useRepositoryList();
|
||||
return (
|
||||
<Page navId="provisioning" subTitle="View and manage your configured repositories">
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<RepositoryListPageContent items={items} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryListPageContent({ items }: { items?: Array<Resource<RepositorySpec>> }) {
|
||||
const [query, setQuery] = useState('');
|
||||
if (!items?.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="call-to-action"
|
||||
message="You haven't created any repository configs yet"
|
||||
button={
|
||||
<LinkButton icon="plus" href={NEW_URL} size="lg">
|
||||
Create repository config
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredItems = items.filter((item) => item.metadata.name.includes(query));
|
||||
|
||||
return (
|
||||
<Stack direction={'column'} gap={3}>
|
||||
<Stack gap={2}>
|
||||
<FilterInput placeholder="Search" value={query} onChange={setQuery} />
|
||||
<LinkButton href={NEW_URL} variant="primary" icon={'plus'}>
|
||||
Add repository config
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
<Stack direction={'column'}>
|
||||
{!!filteredItems.length ? (
|
||||
filteredItems.map((item) => {
|
||||
return (
|
||||
<Card key={item.metadata.name} className={css({ alignItems: 'center' })}>
|
||||
<Stack direction={'column'}>
|
||||
<Card.Heading>{item.spec.title}</Card.Heading>
|
||||
<Card.Meta>{item.spec.type}</Card.Meta>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<LinkButton variant="secondary" href={`${PROVISIONING_URL}/edit/${item.metadata.name}`}>
|
||||
Edit
|
||||
</LinkButton>
|
||||
<DeleteRepositoryButton name={item.metadata.name} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptySearchResult>No results matching your query</EmptySearchResult>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { BackendSrvRequest, getBackendSrv, config } from '@grafana/runtime';
|
||||
|
||||
interface RequestOptions extends BackendSrvRequest {
|
||||
manageError?: (err: unknown) => { error: unknown };
|
||||
showErrorAlert?: boolean;
|
||||
|
||||
// rtk codegen sets this
|
||||
body?: BackendSrvRequest['data'];
|
||||
}
|
||||
|
||||
function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn<RequestOptions> {
|
||||
async function backendSrvBaseQuery(requestOptions: RequestOptions) {
|
||||
try {
|
||||
const { data: responseData, ...meta } = await lastValueFrom(
|
||||
getBackendSrv().fetch({
|
||||
...requestOptions,
|
||||
url: baseURL + requestOptions.url,
|
||||
showErrorAlert: requestOptions.showErrorAlert,
|
||||
data: requestOptions.body,
|
||||
})
|
||||
);
|
||||
return { data: responseData, meta };
|
||||
} catch (error) {
|
||||
return requestOptions.manageError ? requestOptions.manageError(error) : { error };
|
||||
}
|
||||
}
|
||||
|
||||
return backendSrvBaseQuery;
|
||||
}
|
||||
|
||||
export const baseAPI = createApi({
|
||||
reducerPath: 'provisioningAPI',
|
||||
baseQuery: createBackendSrvBaseQuery({
|
||||
baseURL: `/apis/provisioning.grafana.app/v0alpha1/namespaces/${config.namespace}`,
|
||||
}),
|
||||
endpoints: () => ({}),
|
||||
tagTypes: ['RepositoryList'],
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { baseAPI as api } from './baseAPI';
|
||||
import {
|
||||
RepositoryList,
|
||||
RepositoryResource,
|
||||
RequestArg,
|
||||
UpdateRequestArg,
|
||||
HelloWorld,
|
||||
RepositoryForCreate,
|
||||
} from './types';
|
||||
|
||||
const BASE_PATH = '/repositories';
|
||||
|
||||
const injectedRtkApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
// Queries
|
||||
listRepository: build.query<RepositoryList, void>({
|
||||
query: () => ({ url: BASE_PATH }),
|
||||
providesTags: ['RepositoryList'],
|
||||
}),
|
||||
getRepository: build.query<RepositoryResource, { name: string }>({
|
||||
query: ({ name }) => ({ url: `${BASE_PATH}/${name}` }),
|
||||
}),
|
||||
connectGetRepositoryHello: build.query<HelloWorld, { name: string; whom?: string }>({
|
||||
query: ({ name, whom }) => ({
|
||||
url: `${BASE_PATH}/${name}/hello`,
|
||||
params: { whom },
|
||||
}),
|
||||
}),
|
||||
|
||||
// Mutations
|
||||
createRepository: build.mutation<void, RepositoryForCreate>({
|
||||
query: (resource) => ({
|
||||
url: BASE_PATH,
|
||||
method: 'POST',
|
||||
body: resource,
|
||||
}),
|
||||
invalidatesTags: ['RepositoryList'],
|
||||
}),
|
||||
updateRepository: build.mutation<void, UpdateRequestArg>({
|
||||
query: ({ name, body }) => ({
|
||||
url: `${BASE_PATH}/${name}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
deleteRepository: build.mutation<void, RequestArg>({
|
||||
query: ({ name }) => ({
|
||||
url: `${BASE_PATH}/${name}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: ['RepositoryList'],
|
||||
}),
|
||||
patchRepository: build.mutation<void, UpdateRequestArg>({
|
||||
query: ({ name, body }) => ({
|
||||
url: `${BASE_PATH}/${name}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['RepositoryList'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export { injectedRtkApi as generatedAPI };
|
||||
|
||||
export const {
|
||||
useListRepositoryQuery,
|
||||
useCreateRepositoryMutation,
|
||||
useDeleteRepositoryMutation,
|
||||
useGetRepositoryQuery,
|
||||
useUpdateRepositoryMutation,
|
||||
usePatchRepositoryMutation,
|
||||
useConnectGetRepositoryHelloQuery,
|
||||
} = injectedRtkApi;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './endpoints';
|
||||
import { generatedAPI } from './endpoints';
|
||||
|
||||
export const provisioningAPI = generatedAPI.enhanceEndpoints({});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Resource, ResourceForCreate, ResourceList } from '../../apiserver/types';
|
||||
|
||||
export type GitHubRepositoryConfig = {
|
||||
branchWorkflow?: boolean;
|
||||
generateDashboardPreviews?: boolean;
|
||||
owner: string;
|
||||
repository: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type LocalRepositoryConfig = {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type S3RepositoryConfig = {
|
||||
bucket?: string;
|
||||
region?: string;
|
||||
};
|
||||
|
||||
export type RepositorySpec = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
github?: GitHubRepositoryConfig;
|
||||
local?: LocalRepositoryConfig;
|
||||
s3?: S3RepositoryConfig;
|
||||
type: 'github' | 'local' | 's3';
|
||||
};
|
||||
|
||||
export type WatchEvent = {
|
||||
object: unknown;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export interface RequestArg {
|
||||
/** Repository UID */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type RepositoryForCreate = ResourceForCreate<RepositorySpec>;
|
||||
export interface UpdateRequestArg extends RequestArg {
|
||||
/** RepositorySpec */
|
||||
body: ResourceForCreate<RepositorySpec>;
|
||||
}
|
||||
|
||||
export type RepositoryList = ResourceList<RepositorySpec>;
|
||||
|
||||
export type RepositoryResource = Resource<RepositorySpec>;
|
||||
|
||||
export type HelloWorld = {
|
||||
apiVersion?: string;
|
||||
kind?: string;
|
||||
whom?: string;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const PROVISIONING_URL = '/admin/provisioning';
|
||||
export const NEW_URL = `${PROVISIONING_URL}/new`;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useCreateRepositoryMutation, useListRepositoryQuery, useUpdateRepositoryMutation } from './api';
|
||||
import { RepositoryResource, RepositorySpec } from './api/types';
|
||||
|
||||
export function useCreateOrUpdateRepository(name?: string) {
|
||||
const [create, createRequest] = useCreateRepositoryMutation();
|
||||
const [update, updateRequest] = useUpdateRepositoryMutation();
|
||||
|
||||
const updateOrCreate = useCallback(
|
||||
(data: RepositorySpec) => {
|
||||
if (name) {
|
||||
return update({ name, body: { metadata: { name }, spec: data } });
|
||||
}
|
||||
return create({ metadata: { generateName: 'repository' }, spec: { ...data, title: 'test' } });
|
||||
},
|
||||
[create, name, update]
|
||||
);
|
||||
|
||||
return [updateOrCreate, name ? updateRequest : createRequest] as const;
|
||||
}
|
||||
|
||||
// Sort repositories by resourceVersion to show the last modified
|
||||
export function useRepositoryList(): [RepositoryResource[] | undefined, boolean] {
|
||||
const query = useListRepositoryQuery();
|
||||
|
||||
const sortedItems = query.data?.items?.slice().sort((a, b) => {
|
||||
return Number(b.metadata.resourceVersion) - Number(a.metadata.resourceVersion);
|
||||
});
|
||||
|
||||
return [sortedItems, query.isLoading];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { GitHubRepositoryConfig, LocalRepositoryConfig, S3RepositoryConfig } from './api/types';
|
||||
|
||||
export type RepositoryFormData = GitHubRepositoryConfig &
|
||||
S3RepositoryConfig &
|
||||
LocalRepositoryConfig & {
|
||||
title: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
type: 'github' | 'local' | 's3';
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { RepositorySpec } from '../api/types';
|
||||
import { RepositoryFormData } from '../types';
|
||||
|
||||
export const dataToSpec = (data: RepositoryFormData): RepositorySpec => {
|
||||
const spec: RepositorySpec = {
|
||||
type: data.type,
|
||||
folder: data.folder,
|
||||
};
|
||||
switch (data.type) {
|
||||
case 'github':
|
||||
spec.github = {
|
||||
branchWorkflow: data.branchWorkflow,
|
||||
generateDashboardPreviews: data.generateDashboardPreviews,
|
||||
owner: data.owner,
|
||||
repository: data.repository,
|
||||
token: data.token,
|
||||
};
|
||||
break;
|
||||
case 'local':
|
||||
spec.local = {
|
||||
path: data.path,
|
||||
};
|
||||
break;
|
||||
case 's3':
|
||||
spec.s3 = {
|
||||
bucket: data.bucket,
|
||||
region: data.region,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return spec;
|
||||
};
|
||||
|
||||
export const specToData = (spec: RepositorySpec): Partial<RepositoryFormData> => {
|
||||
return {
|
||||
type: spec.type,
|
||||
folder: spec.folder,
|
||||
...spec.github,
|
||||
...spec.local,
|
||||
...spec.s3,
|
||||
};
|
||||
};
|
||||
@@ -533,6 +533,24 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "BookmarksPage"*/ 'app/features/bookmarks/BookmarksPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/admin/provisioning',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "RepositoryListPage"*/ 'app/features/provisioning/RepositoryListPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/admin/provisioning/new',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "NewRepositoryPage"*/ 'app/features/provisioning/NewRepositoryPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/admin/provisioning/edit/:name',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "EditRepositoryPage"*/ 'app/features/provisioning/EditRepositoryPage')
|
||||
),
|
||||
},
|
||||
...getPluginCatalogRoutes(),
|
||||
...getSupportBundleRoutes(),
|
||||
...getAlertingRoutes(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDa
|
||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { cloudMigrationAPI } from 'app/features/migrate-to-cloud/api';
|
||||
import { userPreferencesAPI } from 'app/features/preferences/api';
|
||||
import { provisioningAPI } from 'app/features/provisioning/api';
|
||||
import { StoreState } from 'app/types/store';
|
||||
|
||||
import { buildInitialState } from '../core/reducers/navModel';
|
||||
@@ -41,6 +42,7 @@ export function configureStore(initialState?: Partial<StoreState>) {
|
||||
cloudMigrationAPI.middleware,
|
||||
queryLibraryApi.middleware,
|
||||
userPreferencesAPI.middleware,
|
||||
provisioningAPI.middleware,
|
||||
...extraMiddleware
|
||||
),
|
||||
devTools: process.env.NODE_ENV !== 'production',
|
||||
|
||||
@@ -36,6 +36,11 @@ const config: ConfigFile = {
|
||||
apiImport: 'baseAPI',
|
||||
filterEndpoints: ['getUserPreferences', 'updateUserPreferences', 'patchUserPreferences'],
|
||||
},
|
||||
'../public/app/features/provisioning/api/endpoints.gen.ts': {
|
||||
apiFile: '../public/app/features/provisioning/api/baseApi.ts',
|
||||
schemaFile: '../public/app/features/provisioning/api/spec.json',
|
||||
apiImport: 'baseAPI',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user