Provisioning: Fix the referenced path and generate name (#103424)

This commit is contained in:
Ryan McKinley
2025-04-04 13:31:28 +03:00
committed by GitHub
parent 254286ecaa
commit 02c8669ee8
6 changed files with 85 additions and 38 deletions

View File

@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { AppEvents } from '@grafana/data';
import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { AnnoKeyFolder, AnnoKeySourcePath } from 'app/features/apiserver/types';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
@ -196,12 +197,20 @@ describe('SaveProvisionedDashboardForm', () => {
it('should save a new dashboard successfully', async () => {
const { user, props } = setup();
const newDashboard = {
title: 'New Dashboard',
description: 'New Description',
panels: [],
schemaVersion: 36,
apiVersion: 'dashboard.grafana.app/v1alpha1',
kind: 'Dashboard',
metadata: {
generateName: 'p',
name: undefined,
},
spec: {
title: 'New Dashboard',
description: 'New Description',
panels: [],
schemaVersion: 36,
},
};
props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard);
props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard);
const mockAction = jest.fn();
const mockRequest = { ...mockRequestBase, isSuccess: true };
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
@ -245,14 +254,26 @@ describe('SaveProvisionedDashboardForm', () => {
});
it('should update an existing dashboard successfully', async () => {
const updatedDashboard = {
apiVersion: 'dashboard.grafana.app/vXyz',
metadata: {
name: 'test-dashboard',
annotations: {
[AnnoKeyFolder]: 'folder-uid',
[AnnoKeySourcePath]: 'path/to/file.json',
},
},
spec: { title: 'Test Dashboard', description: 'Test Description' },
};
const { user, props } = setup({
isNew: false,
dashboard: {
useState: () => ({
meta: {
folderUid: 'folder-uid',
folderUid: updatedDashboard.metadata.annotations[AnnoKeyFolder],
slug: 'test-dashboard',
k8s: { name: 'test-dashboard' },
uid: updatedDashboard.metadata.name,
k8s: updatedDashboard.metadata,
},
title: 'Test Dashboard',
description: 'Test Description',
@ -260,7 +281,7 @@ describe('SaveProvisionedDashboardForm', () => {
}),
setState: jest.fn(),
closeModal: jest.fn(),
getSaveAsModel: jest.fn().mockReturnValue({ title: 'Test Dashboard', description: 'Test Description' }),
getSaveResource: jest.fn().mockReturnValue(updatedDashboard),
setManager: jest.fn(),
} as unknown as DashboardScene,
});
@ -268,10 +289,13 @@ describe('SaveProvisionedDashboardForm', () => {
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 });
expect(pathInput).toHaveAttribute('readonly'); // can not edit the path value
pathInput.removeAttribute('readonly'); // save won't get called unless we have a value
await user.clear(pathInput);
await user.type(pathInput, 'path/to/file.json');
const commentInput = screen.getByRole('textbox', { name: /comment/i });
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);
@ -282,9 +306,9 @@ describe('SaveProvisionedDashboardForm', () => {
expect(mockAction).toHaveBeenCalledWith({
ref: undefined,
name: 'test-repo',
path: 'test-dashboard.json',
path: 'path/to/file.json',
message: 'Update dashboard',
body: expect.any(Object),
body: updatedDashboard,
});
});
expect(props.dashboard.closeModal).toHaveBeenCalled();
@ -294,12 +318,20 @@ describe('SaveProvisionedDashboardForm', () => {
it('should show error when save fails', async () => {
const { user, props } = setup();
const newDashboard = {
title: 'New Dashboard',
description: 'New Description',
panels: [],
schemaVersion: 36,
apiVersion: 'dashboard.grafana.app/v1alpha1',
kind: 'Dashboard',
metadata: {
generateName: 'p',
name: undefined,
},
spec: {
title: 'New Dashboard',
description: 'New Description',
panels: [],
schemaVersion: 36,
},
};
props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard);
props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard);
const mockAction = jest.fn();
const mockRequest = {
...mockRequestBase,

View File

@ -59,7 +59,7 @@ export function SaveProvisionedDashboardForm({
}: Props) {
const navigate = useNavigate();
const appEvents = getAppEvents();
const { meta, isDirty, editPanel: panelEditor } = dashboard.useState();
const { isDirty, editPanel: panelEditor } = dashboard.useState();
const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path);
@ -134,24 +134,23 @@ export function SaveProvisionedDashboardForm({
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;
}
const body = dashboard.getSaveResource({
isNew,
title,
description,
});
createOrUpdateFile({
ref,
name: repo,
path,
message: comment,
body: { ...saveModel, uid: meta.uid },
body,
});
};
@ -238,7 +237,7 @@ export function SaveProvisionedDashboardForm({
'File path inside the repository (.json or .yaml)'
)}
>
<Input id="dashboard-path" {...register('path')} />
<Input id="dashboard-path" {...register('path')} readOnly={!isNew} />
</Field>
<Field label={t('dashboard-scene.save-provisioned-dashboard-form.label-comment', 'Comment')}>

View File

@ -49,14 +49,14 @@ describe('generatePath', () => {
expect(result).toBe('folder/path/my-dashboard.json');
});
it('should prepend folderPath to pathFromAnnotation when both are provided', () => {
it('should use pathFromAnnotation when both are provided', () => {
const result = generatePath({
timestamp,
pathFromAnnotation: 'my-custom-path.json',
folderPath: 'folder/path',
pathFromAnnotation: 'full/path/my-custom-path.json', // this is always the full path
folderPath: 'full/path', // this will be a substring
});
expect(result).toBe('folder/path/my-custom-path.json');
expect(result).toBe('full/path/my-custom-path.json');
});
it('should handle empty folderPath', () => {

View File

@ -19,12 +19,12 @@ export function generatePath({ timestamp, pathFromAnnotation, slug, folderPath =
if (pathFromAnnotation) {
const hashIndex = pathFromAnnotation.indexOf('#');
path = hashIndex > 0 ? pathFromAnnotation.substring(0, hashIndex) : pathFromAnnotation;
} else {
const pathSlug = slug || `new-dashboard-${timestamp}`;
path = `${pathSlug}.json`;
return hashIndex > 0 ? pathFromAnnotation.substring(0, hashIndex) : pathFromAnnotation;
}
const pathSlug = slug || `new-dashboard-${timestamp}`;
path = `${pathSlug}.json`;
// Add folder path if it exists
if (folderPath) {
return `${folderPath}/${path}`;

View File

@ -33,7 +33,7 @@ import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { AnnoKeyManagerKind, AnnoKeySourcePath, ManagerKind } from '../../apiserver/types';
import { AnnoKeyManagerKind, AnnoKeySourcePath, ManagerKind, ResourceForCreate } from '../../apiserver/types';
import { DashboardEditPane } from '../edit-pane/DashboardEditPane';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
@ -763,6 +763,22 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
return this.serializer.getSaveModel(this);
}
// Get the dashboard in native K8s form (using the appropriate apiVersion)
getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate<unknown> {
const { meta } = this.state;
const spec = this.getSaveAsModel(options);
return {
apiVersion: 'dashboard.grafana.app/v1alpha1', // get from the dashboard?
kind: 'Dashboard',
metadata: {
...meta.k8s,
name: meta.uid, // ideally the name is preserved
generateName: options.isNew ? 'd' : undefined,
},
spec,
};
}
getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec {
return this.serializer.getSaveAsModel(this, options);
}

View File

@ -16,10 +16,10 @@ interface Props {
export function FolderRepositoryList({ items }: Props) {
const [query, setQuery] = useState('');
const filteredItems = items.filter((item) => item.metadata?.name?.includes(query));
const instanceProvisioned = checkSyncSettings(items);
const { instanceConnected } = checkSyncSettings(items);
return (
<Stack direction={'column'} gap={3}>
{!instanceProvisioned && (
{!instanceConnected && (
<Stack gap={2}>
<FilterInput
placeholder={t('provisioning.folder-repository-list.placeholder-search', 'Search')}