POC/Provisioning: Save dashboard (#97167)

* Create provisioned db

* Redirect to preview

* Enable import for all

* Add "Provisioned" badge

* Add "Provisioned" badge

* Do not show save for provisioned db

* Show existing PRs

* Betterer
This commit is contained in:
Alex Khomenko authored and GitHub committed 2024-11-29 12:32:35 +02:00
1 parent 2972570075
commit 366121b355
8 files changed
+171 -28

No files matched your search

+2 -1
View File
@@ -2074,7 +2074,8 @@ exports[`better eslint`] = {
[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 />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"]
],
"public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
@@ -19,6 +19,7 @@ interface SaveDashboardDrawerState extends SceneObjectState {
saveRefresh?: boolean;
saveAsCopy?: boolean;
onSaveSuccess?: () => void;
saveProvisioned?: boolean;
}
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
@@ -39,7 +40,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
};
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
const { showDiff, saveAsCopy, saveTimeRange, saveVariables, saveRefresh } = model.useState();
const { saveProvisioned, showDiff, saveAsCopy, saveTimeRange, saveVariables, saveRefresh } = model.useState();
const changeInfo = getDashboardChangesFromScene(
model.state.dashboardRef.resolve(),
saveTimeRange,
@@ -51,9 +52,8 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
const dashboard = model.state.dashboardRef.resolve();
const { meta } = dashboard.useState();
const { provisioned: isProvisioned, folderTitle } = meta;
// Provisioned dashboards have k8s metadata annotations
const isProvisionedNG = meta.k8s?.annotations?.[AnnoKeyRepoName];
const isProvisionedNG = saveProvisioned || meta.k8s?.annotations?.[AnnoKeyRepoName];
const tabs = (
<TabsBar>
@@ -1,13 +1,29 @@
import { useEffect } 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 { Alert, Button, Field, Input, LinkButton, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
import {
Alert,
Button,
Field,
Input,
LinkButton,
RadioButtonGroup,
Spinner,
Stack,
TextArea,
TextLink,
} from '@grafana/ui';
import { AnnoKeyRepoName, AnnoKeyRepoPath } from 'app/features/apiserver/types';
import { DashboardMeta } from 'app/types';
import { useGetRepositoryQuery, useUpdateRepositoryFilesMutation } from '../../provisioning/api';
import { RepositorySelect } from '../../provisioning/RepositorySelect';
import { useGetRepositoryQuery } from '../../provisioning/api';
import { RepositorySpec } from '../../provisioning/api/types';
import { PROVISIONING_URL } from '../../provisioning/constants';
import { useCreateOrUpdateRepositoryFile, usePullRequestParam } from '../../provisioning/hooks';
import { WorkflowOption } from '../../provisioning/types';
import { createPRLink, validateBranchName } from '../../provisioning/utils/git';
import { DashboardScene } from '../scene/DashboardScene';
@@ -27,7 +43,7 @@ type FormData = {
function getDefaultValues(meta: DashboardMeta) {
const anno = meta.k8s?.annotations;
let ref = '';
let path = anno?.[AnnoKeyRepoPath] ?? '';
let path = anno?.[AnnoKeyRepoPath] ?? `${meta.slug}.json` ?? '';
const repo = anno?.[AnnoKeyRepoName] ?? '';
const idx = path.indexOf('#');
if (idx > 0) {
@@ -42,6 +58,10 @@ function getDefaultValues(meta: DashboardMeta) {
};
}
const getDefaultWorkflow = (config?: RepositorySpec) => {
return config?.github?.branchWorkflow ? WorkflowOption.Branch : WorkflowOption.PullRequest;
};
function getWorkflowOptions(branch = 'main') {
return [
{ label: `Commit to ${branch}`, value: WorkflowOption.Branch },
@@ -57,26 +77,31 @@ export interface Props {
}
export function SaveProvisionedDashboard({ meta, drawer, changeInfo, dashboard }: Props) {
const [saveDashboard, request] = useUpdateRepositoryFilesMutation();
// Saving as a new provisioned dashboard
const { saveProvisioned } = drawer.useState();
const prURL = usePullRequestParam();
const defaultValues = getDefaultValues(meta);
const repositoryConfigQuery = useGetRepositoryQuery({ name: defaultValues.repo });
const repositoryConfig = repositoryConfigQuery?.data?.spec;
const isGitHub = repositoryConfig?.type === 'github';
const [action, request] = useCreateOrUpdateRepositoryFile(saveProvisioned ? undefined : defaultValues.path);
const {
register,
handleSubmit,
watch,
formState: { errors },
control,
setValue,
} = useForm({
defaultValues: {
...defaultValues,
workflow: repositoryConfig?.github?.branchWorkflow ? WorkflowOption.Branch : WorkflowOption.PullRequest,
workflow: WorkflowOption.PullRequest,
},
});
const [repo, ref, workflow] = watch(['repo', 'ref', 'workflow']);
const [repo, ref, workflow, path] = watch(['repo', 'ref', 'workflow', 'path']);
const repositoryConfigQuery = useGetRepositoryQuery({ name: repo });
const repositoryConfig = repositoryConfigQuery?.data?.spec;
const isGitHub = repositoryConfig?.type === 'github';
const href = createPRLink(repositoryConfig, repo, ref);
const { isDirty } = dashboard.state;
const navigate = useNavigate();
useEffect(() => {
const appEvents = getAppEvents();
@@ -86,13 +111,21 @@ export function SaveProvisionedDashboard({ meta, drawer, changeInfo, dashboard }
payload: ['Dashboard saved'],
});
dashboard.setState({ isDirty: false });
if (saveProvisioned) {
navigate(`${PROVISIONING_URL}/${repo}/dashboard/preview/${path}?ref=${ref}`);
}
} else if (request.isError) {
appEvents.publish({
type: AppEvents.alertError.name,
payload: ['Error saving dashboard', request.error],
});
}
}, [request.isSuccess, request.isError, request.error, dashboard]);
}, [request.isSuccess, request.isError, request.error, dashboard, path, saveProvisioned, navigate, repo, ref]);
useEffect(() => {
setValue('workflow', getDefaultWorkflow(repositoryConfig));
}, [repositoryConfig, setValue]);
const doSave = ({ ref, path, comment, repo }: FormData) => {
if (!repo || !path) {
@@ -102,9 +135,16 @@ export function SaveProvisionedDashboard({ meta, drawer, changeInfo, dashboard }
if (workflow === WorkflowOption.Branch) {
ref = repositoryConfig?.github?.branch || 'main';
}
saveDashboard({ ref, name: repo, path, message: comment, body: changeInfo.changedSaveModel });
action({ ref, name: repo, path, message: comment, body: changeInfo.changedSaveModel });
};
if (repositoryConfigQuery.isLoading) {
return (
<Stack justifyContent={'center'}>
<Spinner />
</Stack>
);
}
return (
<form onSubmit={handleSubmit(doSave)}>
<Stack direction="column" gap={2}>
@@ -117,11 +157,21 @@ export function SaveProvisionedDashboard({ meta, drawer, changeInfo, dashboard }
<SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />
<Field label="Repository">
<Input {...register('repo')} readOnly />
{saveProvisioned ? (
<Controller
control={control}
name={'repo'}
render={({ field: { ref, onChange, ...field } }) => {
return <RepositorySelect {...field} onChange={(v) => onChange(v.value)} />;
}}
/>
) : (
<Input {...register('repo')} readOnly />
)}
</Field>
<Field label="Path" description="File path inside the repository. This must be .json or .yaml">
<Input {...register('path')} readOnly />
<Input {...register('path')} readOnly={!saveProvisioned} />
</Field>
{isGitHub && (
@@ -130,9 +180,9 @@ export function SaveProvisionedDashboard({ meta, drawer, changeInfo, dashboard }
<Controller
control={control}
name={'workflow'}
render={({ field: { ref, ...field } }) => (
<RadioButtonGroup {...field} options={getWorkflowOptions(repositoryConfig.github?.branch)} />
)}
render={({ field: { ref, ...field } }) => {
return <RadioButtonGroup {...field} options={getWorkflowOptions(repositoryConfig.github?.branch)} />;
}}
/>
</Field>
{workflow === WorkflowOption.PullRequest && (
@@ -164,8 +214,16 @@ export function SaveProvisionedDashboard({ meta, drawer, changeInfo, dashboard }
</Alert>
)}
{prURL && (
<Alert severity="info" title="Pull request created">
A pull request has been created with changes to this dashboard:{' '}
<TextLink href={prURL} external>
{prURL}
</TextLink>
</Alert>
)}
<Stack gap={2}>
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty}>
<Button variant="primary" type="submit" disabled={(request.isLoading || !isDirty) && !saveProvisioned}>
{request.isLoading ? 'Saving...' : 'Save'}
</Button>
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
@@ -384,7 +384,15 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
return true;
};
public openSaveDrawer({ saveAsCopy, onSaveSuccess }: { saveAsCopy?: boolean; onSaveSuccess?: () => void }) {
public openSaveDrawer({
saveAsCopy,
saveProvisioned,
onSaveSuccess,
}: {
saveAsCopy?: boolean;
saveProvisioned?: boolean;
onSaveSuccess?: () => void;
}) {
if (!this.state.isEditing) {
return;
}
@@ -394,6 +402,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
dashboardRef: this.getRef(),
saveAsCopy,
onSaveSuccess,
saveProvisioned,
}),
});
}
@@ -26,6 +26,7 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { ScopesSelector } from 'app/features/scopes';
import { AnnoKeyRepoName } from '../../apiserver/types';
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor';
import ExportButton from '../sharing/ExportButton/ExportButton';
@@ -118,6 +119,16 @@ export function ToolbarActions({ dashboard }: Props) {
});
}
if (meta.k8s?.annotations?.[AnnoKeyRepoName]) {
toolbarActions.push({
group: 'icon-actions',
condition: true,
render: () => {
return <Badge color="green" text="Provisioned" key="provisioned-dashboard-button-badge" />;
},
});
}
const isDevEnv = config.buildInfo.env === 'development';
toolbarActions.push({
@@ -548,6 +559,15 @@ export function ToolbarActions({ dashboard }: Props) {
dashboard.openSaveDrawer({ saveAsCopy: true });
}}
/>
{!meta.k8s?.annotations?.[AnnoKeyRepoName] && (
<Menu.Item
label="Save to repository"
icon="github"
onClick={() => {
dashboard.openSaveDrawer({ saveProvisioned: true });
}}
/>
)}
</Menu>
);
@@ -58,10 +58,6 @@ export function ImportFromRepository({ repository }: Props) {
return <Loader />;
}
if (repository.spec?.type !== 'github') {
return null;
}
return (
<>
<Button variant={'secondary'} onClick={() => setIsModalOpen(true)} disabled={importQuery.isLoading || !name}>
@@ -0,0 +1,25 @@
import { Combobox, Spinner } from '@grafana/ui';
import { ComboboxBaseProps } from '@grafana/ui/src/components/Combobox/Combobox';
import { useListRepositoryQuery } from './api';
export function RepositorySelect(props: Pick<ComboboxBaseProps<string>, 'value' | 'onChange'>) {
const listQuery = useListRepositoryQuery();
if (listQuery.isLoading) {
return <Spinner />;
}
return (
<Combobox
{...props}
placeholder={'Select a repository'}
options={
listQuery.data?.items?.map((repo) => ({
label: repo.spec.title,
value: repo.metadata.name,
})) || []
}
/>
);
}
+36 -2
View File
@@ -1,7 +1,15 @@
import { useCallback } from 'react';
import { useCreateRepositoryMutation, useListRepositoryQuery, useUpdateRepositoryMutation } from './api';
import { RepositoryResource, RepositorySpec } from './api/types';
import { useUrlParams } from 'app/core/navigation/hooks';
import {
useCreateRepositoryFilesMutation,
useCreateRepositoryMutation,
useListRepositoryQuery,
useUpdateRepositoryFilesMutation,
useUpdateRepositoryMutation,
} from './api';
import { FileOperationArg, RepositoryResource, RepositorySpec } from './api/types';
export function useCreateOrUpdateRepository(name?: string) {
const [create, createRequest] = useCreateRepositoryMutation();
@@ -30,3 +38,29 @@ export function useRepositoryList(): [RepositoryResource[] | undefined, boolean]
return [sortedItems, query.isLoading];
}
export function useCreateOrUpdateRepositoryFile(name?: string) {
const [create, createRequest] = useCreateRepositoryFilesMutation();
const [update, updateRequest] = useUpdateRepositoryFilesMutation();
const updateOrCreate = useCallback(
(data: FileOperationArg) => {
const actions = name ? update : create;
return actions(data);
},
[create, name, update]
);
return [updateOrCreate, name ? updateRequest : createRequest] as const;
}
export const usePullRequestParam = () => {
const [params] = useUrlParams();
const prParam = params.get('pull_request_url');
if (!prParam) {
return undefined;
}
return decodeURIComponent(prParam);
};