Provisioning: Add dashboard saving functionality (#102269)

* Move dashboard-scene, provisioned dashboard features, and dashboard services/types from grafana-git-ui-sync branch

* Merge

* Update props order

* Fix imports

* Fix imports

* Update dashboard page

* Update imports

* Update test

* Tweaks

* Remove extra mocks

* Split out utils

* Translate

* Revert

* Add translations

* Add comment

* Prettier

* Add comment

* Use AnnoKeyManagerIdentity

* Add manager kind
pull/102303/head
Alex Khomenko 2 months ago committed by GitHub
parent b11daf57bb
commit 71cee10cb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
  2. 14
      public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx
  3. 17
      public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx
  4. 59
      public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx
  5. 41
      public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx
  6. 381
      public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx
  7. 347
      public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx
  8. 25
      public/app/features/dashboard-scene/saving/provisioned/defaults.ts
  9. 91
      public/app/features/dashboard-scene/saving/provisioned/hooks.ts
  10. 71
      public/app/features/dashboard-scene/saving/provisioned/utils/path.test.ts
  11. 34
      public/app/features/dashboard-scene/saving/provisioned/utils/path.ts
  12. 26
      public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.test.ts
  13. 11
      public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.ts
  14. 7
      public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
  15. 5
      public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx
  16. 1
      public/app/features/dashboard/containers/types.ts
  17. 4
      public/app/features/provisioning/hooks/useGetResourceRepository.ts
  18. 3
      public/app/types/dashboard.ts
  19. 37
      public/locales/en-US/grafana.json

@ -23,6 +23,8 @@ export interface Props
export function DashboardScenePage({ route, queryParams, location }: Props) {
const params = useParams();
const { type, slug, uid } = params;
// User by /admin/provisioning/:slug/dashboard/preview/* to load dashboards based on their file path in a remote repository
const path = params['*'];
const prevMatch = usePrevious({ params });
const stateManager = getDashboardScenePageStateManager();
const { dashboard, isLoading, loadError } = stateManager.useState();
@ -34,9 +36,9 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
stateManager.loadSnapshot(slug!);
} else {
stateManager.loadDashboard({
uid: (route.routeName === DashboardRoutes.Provisioning ? path : uid) ?? '',
type,
slug,
uid: uid ?? '',
route: route.routeName as DashboardRoutes,
urlFolderUid: queryParams.folderUid,
});
@ -45,7 +47,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
return () => {
stateManager.clearState();
};
}, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type]);
}, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type, path]);
if (!dashboard) {
let errorElement;

@ -4,9 +4,11 @@ import { UseFormSetValue, useForm } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui';
import { RepositoryView } from 'app/api/clients/provisioning';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from '../../apiserver/types';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared';
@ -131,10 +133,20 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
<Field label="Folder">
<FolderPicker
onChange={(uid: string | undefined, title: string | undefined) => {
onChange={(uid: string | undefined, title: string | undefined, repository?: RepositoryView) => {
const name = repository?.name;
setValue('folder', { uid, title });
const folderUid = dashboard.state.meta.folderUid;
setHasFolderChanged(uid !== folderUid);
dashboard.setState({
// This is necessary to switch to the provisioning flow if a folder is provisioned
meta: {
k8s: name
? { annotations: { [AnnoKeyManagerIdentity]: name, [AnnoKeyManagerKind]: ManagerKind.Repo } }
: undefined,
folderUid: uid,
},
});
}}
// Old folder picker fields
value={formValues.folder?.uid}

@ -1,12 +1,14 @@
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
import { Drawer, Tab, TabsBar } from '@grafana/ui';
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG';
import { DashboardScene } from '../scene/DashboardScene';
import { SaveDashboardAsForm } from './SaveDashboardAsForm';
import { SaveDashboardForm } from './SaveDashboardForm';
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
import { SaveProvisionedDashboard } from './provisioned/SaveProvisionedDashboard';
interface SaveDashboardDrawerState extends SceneObjectState {
dashboardRef: SceneObjectRef<DashboardScene>;
@ -20,7 +22,13 @@ interface SaveDashboardDrawerState extends SceneObjectState {
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
public onClose = () => {
this.state.dashboardRef.resolve().setState({ overlay: undefined });
const dashboard = this.state.dashboardRef.resolve();
const changeInfo = dashboard.getDashboardChanges();
dashboard.setState({
overlay: undefined,
// Reset meta to initial state if it's a new dashboard to remove provisioned fields
meta: changeInfo.isNew ? dashboard.getInitialState()?.meta : dashboard.state.meta,
});
};
public onToggleSaveTimeRange = () => {
@ -47,6 +55,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
const dashboard = model.state.dashboardRef.resolve();
const { meta } = dashboard.useState();
const { provisioned: isProvisioned, folderTitle } = meta;
const isProvisionedNG = useIsProvisionedNG(dashboard);
const tabs = (
<TabsBar>
@ -65,7 +74,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
let title = 'Save dashboard';
if (saveAsCopy) {
title = 'Save dashboard copy';
} else if (isProvisioned) {
} else if (isProvisioned || isProvisionedNG) {
title = 'Provisioned dashboard';
}
@ -84,6 +93,10 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
);
}
if (isProvisionedNG) {
return <SaveProvisionedDashboard dashboard={dashboard} changeInfo={changeInfo} drawer={model} />;
}
if (saveAsCopy || changeInfo.isNew) {
return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} />;
}

@ -0,0 +1,59 @@
import { Button, Modal } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { FolderDTO, FolderListItemDTO } from '../../../../types';
import { NestedFolderDTO } from '../../../search/service/types';
import { DashboardScene } from '../../scene/DashboardScene';
export type FolderDataType = FolderListItemDTO | NestedFolderDTO | FolderDTO;
export interface Props {
onDismiss: () => void;
resource: DashboardScene | FolderDataType;
}
export function ProvisionedResourceDeleteModal({ onDismiss, resource }: Props) {
const type = isDashboard(resource) ? 'dashboard' : 'folder';
return (
<Modal
isOpen={true}
title={t(
'dashboard-scene.provisioned-resource-delete-modal.title-cannot-delete-provisioned-resource',
'Cannot delete provisioned resource'
)}
onDismiss={onDismiss}
>
<>
<p>
<Trans
i18nKey="dashboard-scene.provisioned-resource-delete-modal.managed-by-version-control"
values={{ type }}
>
This {type} is managed by version control and cannot be deleted. To remove it, delete it from the repository
and synchronise to apply the changes.
</Trans>
</p>
{isDashboard(resource) && (
<p>
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.file-path">File path:</Trans>{' '}
{resource.getPath()}
</p>
)}
</>
<Modal.ButtonRow>
<Button variant="primary" onClick={onDismiss}>
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.ok">OK</Trans>
</Button>
</Modal.ButtonRow>
</Modal>
);
}
function isDashboard(resource: DashboardScene | FolderDataType): resource is DashboardScene {
return resource instanceof DashboardScene;
}
export function isFolder(resource: DashboardScene | FolderDataType): resource is FolderDataType {
return !isDashboard(resource);
}

@ -0,0 +1,41 @@
import { useUrlParams } from 'app/core/navigation/hooks';
import { DashboardScene } from '../../scene/DashboardScene';
import { SaveDashboardDrawer } from '../SaveDashboardDrawer';
import { DashboardChangeInfo } from '../shared';
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
import { useDefaultValues } from './hooks';
export interface SaveProvisionedDashboardProps {
dashboard: DashboardScene;
drawer: SaveDashboardDrawer;
changeInfo: DashboardChangeInfo;
}
export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: SaveProvisionedDashboardProps) {
const { meta, title: defaultTitle, description: defaultDescription } = dashboard.useState();
const [params] = useUrlParams();
const loadedFromRef = params.get('ref') ?? undefined;
const defaultValues = useDefaultValues({ meta, defaultTitle, defaultDescription });
if (!defaultValues) {
return null;
}
const { values, isNew, isGitHub, repositoryConfig } = defaultValues;
return (
<SaveProvisionedDashboardForm
dashboard={dashboard}
drawer={drawer}
changeInfo={changeInfo}
isNew={isNew}
defaultValues={values}
loadedFromRef={loadedFromRef}
isGitHub={isGitHub}
repositoryConfig={repositoryConfig}
/>
);
}

@ -0,0 +1,381 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppEvents } from '@grafana/data';
import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
import { DashboardScene } from '../../scene/DashboardScene';
import { SaveDashboardDrawer } from '../SaveDashboardDrawer';
import { SaveProvisionedDashboardForm, Props } from './SaveProvisionedDashboardForm';
jest.mock('@grafana/runtime', () => {
const actual = jest.requireActual('@grafana/runtime');
return {
...actual,
getAppEvents: jest.fn(),
locationService: {
partial: jest.fn(),
},
config: {
...actual.config,
panels: {
debug: {
state: 'alpha',
},
},
},
};
});
jest.mock('app/core/components/Select/FolderPicker', () => {
const actual = jest.requireActual('app/core/components/Select/FolderPicker');
return {
...actual,
FolderPicker: function MockFolderPicker() {
return <div data-testid="folder-picker">Folder Picker</div>;
},
};
});
jest.mock('app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile', () => {
return {
useCreateOrUpdateRepositoryFile: jest.fn(),
};
});
jest.mock('app/features/provisioning/hooks/useGetResourceRepository', () => {
return {
useGetResourceRepository: jest.fn(),
};
});
jest.mock('app/features/provisioning/hooks/useRepositoryList', () => {
return {
useRepositoryList: jest.fn(),
};
});
jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => {
const actual = jest.requireActual('app/features/manage-dashboards/services/ValidationSrv');
return {
...actual,
validationSrv: {
validateNewDashboardName: jest.fn(),
},
};
});
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
...actual,
useNavigate: () => jest.fn(),
};
});
jest.mock('../SaveDashboardForm', () => {
const actual = jest.requireActual('../SaveDashboardForm');
return {
...actual,
SaveDashboardFormCommonOptions: () => <div data-testid="common-options">Common Options</div>,
};
});
function setup(props: Partial<Props> = {}) {
const user = userEvent.setup();
const mockDashboard: Dashboard = {
title: 'Test Dashboard',
panels: [],
schemaVersion: 36,
};
const defaultProps: Props = {
dashboard: {
useState: () => ({
meta: { folderUid: 'folder-uid', slug: 'test-dashboard' },
title: 'Test Dashboard',
description: 'Test Description',
isDirty: true,
}),
setState: jest.fn(),
closeModal: jest.fn(),
getSaveAsModel: jest.fn().mockReturnValue(mockDashboard),
setManager: jest.fn(),
} as unknown as DashboardScene,
drawer: {
onClose: jest.fn(),
} as unknown as SaveDashboardDrawer,
changeInfo: {
changedSaveModel: mockDashboard,
initialSaveModel: mockDashboard,
diffCount: 0,
hasChanges: true,
hasTimeChanges: false,
hasVariableValueChanges: false,
hasRefreshChange: false,
diffs: {},
},
isNew: true,
isGitHub: true,
defaultValues: {
ref: 'dashboard/2023-01-01-abcde',
path: 'test-dashboard.json',
repo: 'test-repo',
comment: '',
folder: { uid: 'folder-uid', title: '' },
title: 'Test Dashboard',
description: 'Test Description',
workflow: 'write',
},
repositoryConfig: {
type: 'github',
workflows: ['write', 'branch'],
sync: { enabled: false, target: 'folder' },
title: 'Test Repository',
github: {
branch: 'main',
},
},
...props,
};
return {
user,
props: defaultProps,
...render(<SaveProvisionedDashboardForm {...defaultProps} />),
};
}
const mockRequestBase = {
isSuccess: true,
isError: false,
isLoading: false,
error: null,
data: { resource: { upsert: {} } },
};
describe('SaveProvisionedDashboardForm', () => {
beforeEach(() => {
jest.clearAllMocks();
(getAppEvents as jest.Mock).mockReturnValue({ publish: jest.fn() });
(validationSrv.validateNewDashboardName as jest.Mock).mockResolvedValue(true);
const mockRequest = { ...mockRequestBase, isSuccess: false };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([jest.fn(), mockRequest]);
});
it('should render the form with correct fields for a new dashboard', () => {
setup();
expect(screen.getByRole('form')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /title/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /description/i })).toBeInTheDocument();
expect(screen.getByTestId('folder-picker')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /path/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument();
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('should render the form with correct fields for an existing dashboard', () => {
// existing dashboards show "Common Options" instead of the title/desc fields
setup({ isNew: false });
expect(screen.getByTestId('common-options')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /path/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument();
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /title/i })).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /description/i })).not.toBeInTheDocument();
});
it('should save a new dashboard successfully', async () => {
const { user, props } = setup();
const newDashboard = {
title: 'New Dashboard',
description: 'New Description',
panels: [],
schemaVersion: 36,
};
props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard);
const mockAction = jest.fn();
const mockRequest = { ...mockRequestBase, isSuccess: true };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const titleInput = screen.getByRole('textbox', { name: /title/i });
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
const pathInput = screen.getByRole('textbox', { name: /path/i });
const commentInput = screen.getByRole('textbox', { name: /comment/i });
await user.clear(titleInput);
await user.clear(descriptionInput);
await user.clear(pathInput);
await user.clear(commentInput);
await user.type(titleInput, 'New Dashboard');
await user.type(descriptionInput, 'New Description');
await user.type(pathInput, 'test-dashboard.json');
await user.type(commentInput, 'Initial commit');
const submitButton = screen.getByRole('button', { name: /save/i });
await user.click(submitButton);
await waitFor(() => {
expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
});
await waitFor(() => {
expect(mockAction).toHaveBeenCalledWith({
ref: undefined,
name: 'test-repo',
path: 'test-dashboard.json',
message: 'Initial commit',
body: newDashboard,
});
});
const appEvents = getAppEvents();
expect(appEvents.publish).toHaveBeenCalledWith({
type: AppEvents.alertSuccess.name,
payload: ['Dashboard changes saved'],
});
expect(props.dashboard.closeModal).toHaveBeenCalled();
expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
});
it('should update an existing dashboard successfully', async () => {
const { user, props } = setup({
isNew: false,
dashboard: {
useState: () => ({
meta: {
folderUid: 'folder-uid',
slug: 'test-dashboard',
k8s: { name: 'test-dashboard' },
},
title: 'Test Dashboard',
description: 'Test Description',
isDirty: true,
}),
setState: jest.fn(),
closeModal: jest.fn(),
getSaveAsModel: jest.fn().mockReturnValue({ title: 'Test Dashboard', description: 'Test Description' }),
setManager: jest.fn(),
} as unknown as DashboardScene,
});
const mockAction = jest.fn();
const mockRequest = { ...mockRequestBase, isSuccess: true };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const pathInput = screen.getByRole('textbox', { name: /path/i });
const commentInput = screen.getByRole('textbox', { name: /comment/i });
await user.clear(pathInput);
await user.clear(commentInput);
await user.type(pathInput, 'test-dashboard.json');
await user.type(commentInput, 'Update dashboard');
const submitButton = screen.getByRole('button', { name: /save/i });
await user.click(submitButton);
await waitFor(() => {
expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
});
await waitFor(() => {
expect(mockAction).toHaveBeenCalledWith({
ref: undefined,
name: 'test-repo',
path: 'test-dashboard.json',
message: 'Update dashboard',
body: expect.any(Object),
});
});
expect(props.dashboard.closeModal).toHaveBeenCalled();
expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
});
it('should show error when save fails', async () => {
const { user, props } = setup();
const newDashboard = {
title: 'New Dashboard',
description: 'New Description',
panels: [],
schemaVersion: 36,
};
props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard);
const mockAction = jest.fn();
const mockRequest = {
...mockRequestBase,
isSuccess: false,
isError: true,
error: 'Failed to save dashboard',
};
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
const titleInput = screen.getByRole('textbox', { name: /title/i });
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
const pathInput = screen.getByRole('textbox', { name: /path/i });
const commentInput = screen.getByRole('textbox', { name: /comment/i });
await user.clear(titleInput);
await user.clear(descriptionInput);
await user.clear(pathInput);
await user.clear(commentInput);
await user.type(titleInput, 'New Dashboard');
await user.type(descriptionInput, 'New Description');
await user.type(pathInput, 'error-dashboard.json');
await user.type(commentInput, 'Error commit');
const submitButton = screen.getByRole('button', { name: /save/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockAction).toHaveBeenCalledWith({
ref: undefined,
name: 'test-repo',
path: 'error-dashboard.json',
message: 'Error commit',
body: newDashboard,
});
});
await waitFor(() => {
const appEvents = getAppEvents();
expect(appEvents.publish).toHaveBeenCalledWith({
type: AppEvents.alertError.name,
payload: ['Error saving dashboard', 'Failed to save dashboard'],
});
});
});
it('should disable save button when dashboard is not dirty', () => {
setup({
dashboard: {
useState: () => ({
meta: {
folderUid: 'folder-uid',
slug: 'test-dashboard',
k8s: { name: 'test-dashboard' },
},
title: 'Test Dashboard',
description: 'Test Description',
isDirty: false,
}),
setState: jest.fn(),
closeModal: jest.fn(),
getSaveAsModel: jest.fn().mockReturnValue({}),
setManager: jest.fn(),
} as unknown as DashboardScene,
});
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});
it('should show read-only alert when repository has no workflows', () => {
setup({
repositoryConfig: {
type: 'github',
workflows: [],
sync: { enabled: false, target: 'folder' },
title: 'Read-only Repository',
},
});
expect(screen.getByText('This repository is read only')).toBeInTheDocument();
});
});

@ -0,0 +1,347 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents, locationUtil } from '@grafana/data';
import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { Alert, Button, Field, Input, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
import { RepositorySpec, RepositoryView } from 'app/api/clients/provisioning';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { t, Trans } from 'app/core/internationalization';
import kbn from 'app/core/utils/kbn';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind, Resource } from 'app/features/apiserver/types';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
import { WorkflowOption } from 'app/features/provisioning/types';
import { validateBranchName } from 'app/features/provisioning/utils/git';
import { getDashboardUrl } from '../../utils/getDashboardUrl';
import { SaveDashboardFormCommonOptions } from '../SaveDashboardForm';
import { SaveProvisionedDashboardProps } from './SaveProvisionedDashboard';
import { getWorkflowOptions } from './defaults';
type FormData = {
ref?: string;
path: string;
comment?: string;
repo: string;
workflow?: WorkflowOption;
title: string;
description: string;
folder: {
uid?: string;
title?: string;
};
};
export interface Props extends SaveProvisionedDashboardProps {
isNew: boolean;
defaultValues: FormData;
isGitHub: boolean;
repositoryConfig?: RepositorySpec;
loadedFromRef?: string;
}
export function SaveProvisionedDashboardForm({
defaultValues,
dashboard,
drawer,
changeInfo,
isNew,
loadedFromRef,
repositoryConfig,
isGitHub,
}: Props) {
const navigate = useNavigate();
const appEvents = getAppEvents();
const { meta, isDirty } = dashboard.useState();
const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path);
const {
register,
handleSubmit,
watch,
formState: { errors },
control,
setValue,
} = useForm<FormData>({ defaultValues });
const [ref, workflow, path] = watch(['ref', 'workflow', 'path']);
useEffect(() => {
if (request.isSuccess) {
dashboard.setState({ isDirty: false });
if (workflow === 'branch' && ref !== '' && path !== '') {
// Redirect to the provisioning preview pages
navigate(`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?ref=${ref}`);
return;
}
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-success', 'Dashboard changes saved')],
});
// Load the new URL
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const upsert = request.data.resource.upsert as Resource<Dashboard>;
if (isNew && upsert?.metadata?.name) {
const url = locationUtil.assureBaseUrl(
getDashboardUrl({
uid: upsert.metadata.name,
slug: kbn.slugifyForUrl(upsert.spec.title ?? ''),
currentQueryParams: window.location.search,
})
);
navigate(url);
return;
}
// Keep the same URL
dashboard.closeModal();
locationService.partial({
viewPanel: null,
editPanel: null,
});
} else if (request.isError) {
appEvents.publish({
type: AppEvents.alertError.name,
payload: [
t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'),
request.error,
],
});
}
}, [appEvents, dashboard, defaultValues.repo, isNew, navigate, path, ref, request, workflow]);
// Submit handler for saving the form data
const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: FormData) => {
if (!repo || !path) {
return;
}
// The dashboard spec
const saveModel = dashboard.getSaveAsModel({
isNew,
title,
description,
});
// If user is writing to the original branch, override ref with whatever we loaded from
if (workflow === 'write') {
ref = loadedFromRef;
}
createOrUpdateFile({
ref,
name: repo,
path,
message: comment,
body: { ...saveModel, uid: meta.uid },
});
};
const workflowOptions = getWorkflowOptions(repositoryConfig, loadedFromRef);
return (
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
<Stack direction="column" gap={2}>
{!repositoryConfig?.workflows.length && (
<Alert
title={t(
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.copy-json-message">
If you have direct access to the target, copy the JSON and paste it there.
</Trans>
</Alert>
)}
{isNew && (
<>
<Field
label={t('dashboard-scene.save-provisioned-dashboard-form.label-title', 'Title')}
invalid={!!errors.title}
error={errors.title?.message}
>
<Input
id="dashboard-title"
{...register('title', {
required: t(
'dashboard-scene.save-provisioned-dashboard-form.title-required',
'Dashboard title is required'
),
validate: validateTitle,
})}
/>
</Field>
<Field
label={t('dashboard-scene.save-provisioned-dashboard-form.label-description', 'Description')}
invalid={!!errors.description}
error={errors.description?.message}
>
<TextArea id="dashboard-description" {...register('description')} />
</Field>
<Field label={t('dashboard-scene.save-provisioned-dashboard-form.label-target-folder', 'Target folder')}>
<Controller
control={control}
name={'folder'}
render={({ field: { ref, value, onChange, ...field } }) => {
return (
<FolderPicker
inputId="dashboard-folder"
onChange={(uid?: string, title?: string, repository?: RepositoryView) => {
onChange({ uid, title });
const name = repository?.name;
if (name) {
setValue('repo', name);
}
dashboard.setState({
meta: {
k8s: name
? {
annotations: {
[AnnoKeyManagerIdentity]: name,
[AnnoKeyManagerKind]: ManagerKind.Repo,
},
}
: undefined,
folderUid: uid,
},
});
}}
value={value.uid}
{...field}
/>
);
}}
/>
</Field>
</>
)}
{!isNew && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
<Field
label={t('dashboard-scene.save-provisioned-dashboard-form.label-path', 'Path')}
description={t(
'dashboard-scene.save-provisioned-dashboard-form.description-inside-repository',
'File path inside the repository (.json or .yaml)'
)}
>
<Input id="dashboard-path" {...register('path')} />
</Field>
<Field label={t('dashboard-scene.save-provisioned-dashboard-form.label-comment', 'Comment')}>
<TextArea
id="dashboard-comment"
{...register('comment')}
placeholder={t(
'dashboard-scene.save-provisioned-dashboard-form.dashboard-comment-placeholder-describe-changes-optional',
'Add a note to describe your changes (optional)'
)}
rows={5}
/>
</Field>
{isGitHub && (
<>
<Field label={t('dashboard-scene.save-provisioned-dashboard-form.label-workflow', 'Workflow')}>
<Controller
control={control}
name="workflow"
render={({ field: { ref: _, ...field } }) => (
<RadioButtonGroup id="dashboard-workflow" {...field} options={workflowOptions} />
)}
/>
</Field>
{workflow === 'branch' && (
<Field
label={t('dashboard-scene.save-provisioned-dashboard-form.label-branch', 'Branch')}
description={t(
'dashboard-scene.save-provisioned-dashboard-form.description-branch-name-in-git-hub',
'Branch name in GitHub'
)}
invalid={!!errors.ref}
error={errors.ref && <BranchValidationError />}
>
<Input id="dashboard-branch" {...register('ref', { validate: validateBranchName })} />
</Field>
)}
</>
)}
<Stack gap={2}>
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty}>
{request.isLoading
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
</Button>
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans>
</Button>
</Stack>
</Stack>
</form>
);
}
const BranchValidationError = () => (
<>
<Trans i18nKey="dashboard-scene.branch-validation-error.invalid-branch-name">Invalid branch name.</Trans>
<ul style={{ padding: '0 20px' }}>
<li>
<Trans i18nKey="dashboard-scene.branch-validation-error.cannot-start-with">
It cannot start with '/' or end with '/', '.', or whitespace.
</Trans>
</li>
<li>
<Trans i18nKey="dashboard-scene.branch-validation-error.it-cannot-contain-or">
It cannot contain '//' or '..'.
</Trans>
</li>
<li>
<Trans i18nKey="dashboard-scene.branch-validation-error.cannot-contain-invalid-characters">
It cannot contain invalid characters: '~', '^', ':', '?', '*', '[', '\\', or ']'.
</Trans>
</li>
<li>
<Trans i18nKey="dashboard-scene.branch-validation-error.least-valid-character">
It must have at least one valid character.
</Trans>
</li>
</ul>
</>
);
/**
* Dashboard title validation to ensure it's not the same as the folder name
* and meets other naming requirements.
*/
async function validateTitle(title: string, formValues: FormData) {
if (title === formValues.folder.title?.trim()) {
return t(
'dashboard-scene.save-provisioned-dashboard-form.title-same-as-folder',
'Dashboard name cannot be the same as the folder name'
);
}
try {
await validationSrv.validateNewDashboardName(formValues.folder.uid ?? 'general', title);
return true;
} catch (error) {
return error instanceof Error
? error.message
: t(
'dashboard-scene.save-provisioned-dashboard-form.title-validation-failed',
'Dashboard title validation failed.'
);
}
}

@ -0,0 +1,25 @@
import { RepositorySpec } from 'app/api/clients/provisioning';
import { WorkflowOption } from 'app/features/provisioning/types';
export function getDefaultWorkflow(config?: RepositorySpec) {
return config?.workflows?.[0];
}
export function getWorkflowOptions(config?: RepositorySpec, ref?: string) {
if (!config) {
return [];
}
if (config.local?.path) {
return [{ label: `Write to ${config.local.path}`, value: 'write' }];
}
let branch = ref ?? config.github?.branch;
const availableOptions: Array<{ label: string; value: WorkflowOption }> = [
{ label: `Push to ${branch ?? 'main'}`, value: 'write' },
{ label: 'Push to different branch', value: 'branch' },
];
// Filter options based on the workflows in the config
return availableOptions.filter((option) => config.workflows?.includes(option.value));
}

@ -0,0 +1,91 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useGetFolderQuery } from 'app/api/clients/folder';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types';
import { useGetResourceRepository } from 'app/features/provisioning/hooks/useGetResourceRepository';
import { useRepositoryList } from 'app/features/provisioning/hooks/useRepositoryList';
import { DashboardMeta } from 'app/types';
import { getDefaultWorkflow } from './defaults';
import { generatePath } from './utils/path';
import { generateTimestamp } from './utils/timestamp';
interface UseDefaultValuesParams {
meta: DashboardMeta;
defaultTitle: string;
defaultDescription?: string;
}
export function useDefaultValues({ meta, defaultTitle, defaultDescription }: UseDefaultValuesParams) {
const annotations = meta.k8s?.annotations;
const managerKind = annotations?.[AnnoKeyManagerKind];
const managerIdentity = annotations?.[AnnoKeyManagerIdentity];
const sourcePath = annotations?.[AnnoKeySourcePath];
const repositoryConfig = useConfig({ folderUid: meta.folderUid, managerKind, managerIdentity });
const repository = repositoryConfig?.spec;
const timestamp = generateTimestamp();
// Get folder data to retrieve the folder path
const folderQuery = useGetFolderQuery(meta.folderUid ? { name: meta.folderUid } : skipToken);
const folderPath = meta.folderUid ? (folderQuery.data?.metadata?.annotations?.[AnnoKeySourcePath] ?? '') : '';
const dashboardPath = generatePath({
timestamp,
pathFromAnnotation: sourcePath,
slug: meta.slug,
folderPath,
});
if (folderQuery.isLoading || !repositoryConfig) {
return null;
}
return {
values: {
ref: `dashboard/${timestamp}`,
path: dashboardPath,
repo: managerIdentity || repositoryConfig?.metadata?.name || '',
comment: '',
folder: {
uid: meta.folderUid,
title: '',
},
title: defaultTitle,
description: defaultDescription ?? '',
workflow: getDefaultWorkflow(repository),
},
isNew: !meta.k8s?.name,
repositoryConfig: repository,
isGitHub: repository?.type === 'github',
};
}
type UseConfigArgs = {
folderUid?: string;
managerKind?: string;
managerIdentity?: string;
};
const useConfig = ({ folderUid, managerKind, managerIdentity }: UseConfigArgs) => {
const repositoryConfig = useGetResourceRepository({
name: managerKind === 'repo' ? managerIdentity : undefined,
folderUid,
});
const [items, isLoading] = useRepositoryList(repositoryConfig ? skipToken : undefined);
if (repositoryConfig) {
return repositoryConfig;
}
if (isLoading) {
return null;
}
const instanceConfig = items?.find((repo) => repo.spec?.sync.target === 'instance');
if (instanceConfig) {
return instanceConfig;
}
// Return the config, which targets the folder
return items?.find((repo) => repo?.metadata?.name === folderUid);
};

@ -0,0 +1,71 @@
import { generatePath } from './path';
describe('generatePath', () => {
const timestamp = '2023-05-15-abcde';
it('should generate path using slug when pathFromAnnotation is not provided', () => {
const result = generatePath({
timestamp,
slug: 'my-dashboard',
});
expect(result).toBe('my-dashboard.json');
});
it('should use default slug with timestamp when neither pathFromAnnotation nor slug is provided', () => {
const result = generatePath({
timestamp,
});
expect(result).toBe('new-dashboard-2023-05-15-abcde.json');
});
it('should use pathFromAnnotation when provided', () => {
const result = generatePath({
timestamp,
pathFromAnnotation: 'dashboards/my-custom-path.json',
slug: 'my-dashboard', // This should be ignored when pathFromAnnotation is provided
});
expect(result).toBe('dashboards/my-custom-path.json');
});
it('should remove hash from pathFromAnnotation', () => {
const result = generatePath({
timestamp,
pathFromAnnotation: 'dashboards/my-custom-path.json#some-hash',
});
expect(result).toBe('dashboards/my-custom-path.json');
});
it('should prepend folderPath when provided', () => {
const result = generatePath({
timestamp,
slug: 'my-dashboard',
folderPath: 'folder/path',
});
expect(result).toBe('folder/path/my-dashboard.json');
});
it('should prepend folderPath to pathFromAnnotation when both are provided', () => {
const result = generatePath({
timestamp,
pathFromAnnotation: 'my-custom-path.json',
folderPath: 'folder/path',
});
expect(result).toBe('folder/path/my-custom-path.json');
});
it('should handle empty folderPath', () => {
const result = generatePath({
timestamp,
slug: 'my-dashboard',
folderPath: '',
});
expect(result).toBe('my-dashboard.json');
});
});

@ -0,0 +1,34 @@
/**
* Parameters for generating a dashboard path
*/
export interface GeneratePathParams {
timestamp: string;
pathFromAnnotation?: string;
slug?: string;
folderPath?: string;
}
/**
* Generates a path for a dashboard based on provided parameters
* If pathFromAnnotation is provided, it will be used as the base path
* Otherwise, a path will be generated using the slug or a default name with timestamp
* If folderPath is provided, it will be prepended to the path
*/
export function generatePath({ timestamp, pathFromAnnotation, slug, folderPath = '' }: GeneratePathParams): string {
let path = '';
if (pathFromAnnotation) {
const hashIndex = pathFromAnnotation.indexOf('#');
path = hashIndex > 0 ? pathFromAnnotation.substring(0, hashIndex) : pathFromAnnotation;
} else {
const pathSlug = slug || `new-dashboard-${timestamp}`;
path = `${pathSlug}.json`;
}
// Add folder path if it exists
if (folderPath) {
return `${folderPath}/${path}`;
}
return path;
}

@ -0,0 +1,26 @@
import { generateTimestamp } from './timestamp';
describe('generateTimestamp', () => {
it('should generate a timestamp in the expected format', () => {
const timestamp = generateTimestamp();
// Check that the timestamp is a string
expect(typeof timestamp).toBe('string');
// Check that the timestamp follows the format YYYY-MM-DD-xxxxx
// where xxxxx is a random string of 5 alphabetic characters
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}-[a-zA-Z]{5}$/);
});
it('should generate unique timestamps', () => {
// Generate multiple timestamps and check that they're different
const timestamp1 = generateTimestamp();
const timestamp2 = generateTimestamp();
const timestamp3 = generateTimestamp();
// The date part might be the same, but the random part should make them different
expect(timestamp1).not.toBe(timestamp2);
expect(timestamp1).not.toBe(timestamp3);
expect(timestamp2).not.toBe(timestamp3);
});
});

@ -0,0 +1,11 @@
import { Chance } from 'chance';
import { dateTime } from '@grafana/data';
/**
* Generates a timestamp string in the format YYYY-MM-DD-xxxxx where xxxxx is a random string
*/
export function generateTimestamp(): string {
const random = new Chance();
return `${dateTime().format('YYYY-MM-DD')}-${random.string({ length: 5, alpha: true })}`;
}

@ -25,8 +25,10 @@ import { contextSrv } from 'app/core/core';
import { Trans, t } from 'app/core/internationalization';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { useSelector } from 'app/types';
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
import { selectFolderRepository } from '../../provisioning/utils/selectors';
import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor';
import ExportButton from '../sharing/ExportButton/ExportButton';
import ShareButton from '../sharing/ShareButton/ShareButton';
@ -76,7 +78,8 @@ export function ToolbarActions({ dashboard }: Props) {
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
const isManaged = Boolean(dashboard.isManaged());
const folderRepo = useSelector((state) => selectFolderRepository(state, meta.folderUid));
const isManaged = Boolean(dashboard.isManaged() || folderRepo);
if (!isEditingPanel) {
// This adds the presence indicators in enterprise
@ -130,7 +133,7 @@ export function ToolbarActions({ dashboard }: Props) {
group: 'icon-actions',
condition: true,
render: () => {
return <ManagedDashboardNavBarBadge meta={meta} />;
return <ManagedDashboardNavBarBadge meta={meta} key="managed-dashboard-badge" />;
},
});
}

@ -6,6 +6,7 @@ import { Button, ConfirmModal, Modal, Space, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
import { ProvisionedResourceDeleteModal } from '../saving/provisioned/ProvisionedResourceDeleteModal';
import { DashboardScene } from '../scene/DashboardScene';
interface ButtonProps {
@ -53,6 +54,10 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) {
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
}
if (dashboard.isManagedRepository() && showModal) {
return <ProvisionedResourceDeleteModal resource={dashboard} onDismiss={toggleModal} />;
}
return (
<>
<Button

@ -20,6 +20,7 @@ export type DashboardPageRouteSearchParams = {
kiosk?: string | true;
scenes?: boolean;
shareView?: string;
ref?: string; // used for repo preview
};
export type PublicDashboardPageRouteParams = {

@ -1,7 +1,7 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useGetFolderQuery } from '../../../api/clients/folder';
import { AnnoKeyManagerKind } from '../../apiserver/types';
import { AnnoKeyManagerIdentity } from '../../apiserver/types';
import { useRepositoryList } from './useRepositoryList';
@ -15,7 +15,7 @@ export const useGetResourceRepository = ({ name, folderUid }: GetResourceReposit
// 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];
const repoName = name || folderQuery.data?.metadata?.annotations?.[AnnoKeyManagerIdentity];
if (!items?.length || isLoading || !repoName) {
return undefined;

@ -2,8 +2,7 @@ import { DataQuery } from '@grafana/data';
import { Dashboard, DataSourceRef } from '@grafana/schema';
import { ObjectMeta } from 'app/features/apiserver/types';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { ProvisioningPreview } from '../features/provisioning/types';
import { ProvisioningPreview } from 'app/features/provisioning/types';
export interface HomeDashboardRedirectDTO {
redirectUri: string;

@ -1413,6 +1413,43 @@
"title": "There are no dashboard links added yet"
}
},
"dashboard-scene": {
"branch-validation-error": {
"cannot-contain-invalid-characters": "It cannot contain invalid characters: '~', '^', ':', '?', '*', '[', '\\\\', or ']'.",
"cannot-start-with": "It cannot start with '/' or end with '/', '.', or whitespace.",
"invalid-branch-name": "Invalid branch name.",
"it-cannot-contain-or": "It cannot contain '//' or '..'.",
"least-valid-character": "It must have at least one valid character."
},
"provisioned-resource-delete-modal": {
"file-path": "File path:",
"managed-by-version-control": "This {type} is managed by version control and cannot be deleted. To remove it, delete it from the repository and synchronise to apply the changes.",
"ok": "OK",
"title-cannot-delete-provisioned-resource": "Cannot delete provisioned resource"
},
"save-provisioned-dashboard-form": {
"api-error": "Error saving dashboard",
"api-success": "Dashboard changes saved",
"cancel": "Cancel",
"copy-json-message": "If you have direct access to the target, copy the JSON and paste it there.",
"dashboard-comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
"description-branch-name-in-git-hub": "Branch name in GitHub",
"description-inside-repository": "File path inside the repository (.json or .yaml)",
"label-branch": "Branch",
"label-comment": "Comment",
"label-description": "Description",
"label-path": "Path",
"label-target-folder": "Target folder",
"label-title": "Title",
"label-workflow": "Workflow",
"save": "Save",
"saving": "Saving...",
"title-required": "Dashboard title is required",
"title-same-as-folder": "Dashboard name cannot be the same as the folder name",
"title-this-repository-is-read-only": "This repository is read only",
"title-validation-failed": "Dashboard title validation failed."
}
},
"dashboard-settings": {
"annotations": {
"title": "Annotations"

Loading…
Cancel
Save