Dashboard Schema V2: Import Dashboard (#102375)

* WIP: Working import

* wip

* remove model

* wip

* working

* remove console.logs

* fix

* remove

* remove uid field as v2 spec doesn't have uid

* clean up reducer

* revert arrow func

* support upload
This commit is contained in:
Haris Rozajac
2025-04-11 12:18:48 -06:00
committed by GitHub
parent 17fbeb09f1
commit 6933829ce2
6 changed files with 456 additions and 5 deletions
+21
View File
@@ -1643,6 +1643,27 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
],
"public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Do not use any type assertions.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
],
"public/app/features/dashboard-scene/v2schema/test-helpers.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
@@ -64,7 +64,6 @@ LibraryPanelSpec: {
id: number
// Title for the library panel in the dashboard
title: string
libraryPanel: LibraryPanelRef
}
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { ExpressionDatasourceRef } from '@grafana/runtime/internal';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { t, Trans } from 'app/core/internationalization';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { DashboardInputs, DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
import { validateTitle } from 'app/features/manage-dashboards/utils/validation';
interface Props
extends Pick<
UseFormReturn<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>,
'register' | 'control' | 'getValues' | 'watch'
> {
inputs: DashboardInputs;
uidReset: boolean;
errors: FieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>;
onCancel: () => void;
onUidReset: () => void;
onSubmit: FormsOnSubmit<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>;
}
export const ImportDashboardFormV2 = ({
register,
errors,
control,
inputs,
getValues,
uidReset,
onUidReset,
onCancel,
onSubmit,
watch,
}: Props) => {
const [isSubmitted, setSubmitted] = useState(false);
const [selectedDataSources, setSelectedDataSources] = useState<Record<string, { uid: string; type: string }>>({});
/*
This useEffect is needed for overwriting a dashboard. It
submits the form even if there's validation errors on title or uid.
*/
useEffect(() => {
if (isSubmitted && (errors.dashboard?.title || errors.k8s?.name)) {
const formValues = getValues();
onSubmit({
...formValues,
dashboard: {
...formValues.dashboard,
title: formValues.dashboard.title,
},
});
}
}, [errors, getValues, isSubmitted, onSubmit]);
return (
<>
<Trans i18nKey="manage-dashboards.import-dashboard-form.options">Options</Trans>
<Field
label={t('manage-dashboards.import-dashboard-form.label-name', 'Name')}
invalid={!!errors.dashboard?.title}
error={errors.dashboard?.title && errors.dashboard?.title.message}
>
<Input
{...(register as any)('dashboard.title', {
required: 'Name is required',
validate: async (v: string) => await validateTitle(v, getValues().folderUid ?? ''),
})}
type="text"
data-testid={selectors.components.ImportDashboardForm.name}
/>
</Field>
<Field label="Folder">
<Controller<any>
render={({ field: { ref, value, onChange, ...field } }) => (
<FolderPicker
{...field}
onChange={(uid, title) => {
onChange(uid, title);
}}
value={value}
/>
)}
name="folderUid"
control={control}
/>
</Field>
{inputs.dataSources &&
inputs.dataSources.map((input: DataSourceInput) => {
if (input.pluginId === ExpressionDatasourceRef.type) {
return null;
}
const dataSourceOption = `datasource-${input.pluginId}` as const;
return (
<Field
label={input.label}
description={input.description}
key={input.pluginId}
invalid={!!errors[dataSourceOption]}
error={errors[dataSourceOption] ? 'Please select a data source' : undefined}
>
<Controller<any>
name={dataSourceOption}
render={({ field: { ref, ...field } }) => (
<DataSourcePicker
{...field}
noDefault={true}
placeholder={input.info}
pluginId={input.pluginId}
current={selectedDataSources[input.pluginId]}
onChange={(ds) => {
field.onChange(ds);
// Update our selected datasources map
setSelectedDataSources((prev) => ({
...prev,
[input.pluginId]: {
uid: ds.uid,
type: ds.type,
},
}));
}}
/>
)}
control={control}
rules={{ required: true }}
/>
</Field>
);
})}
<Stack>
<Button
type="submit"
data-testid={selectors.components.ImportDashboardForm.submit}
variant={getButtonVariant(errors)}
onClick={() => {
setSubmitted(true);
}}
>
{getButtonText(errors)}
</Button>
<Button type="reset" variant="secondary" onClick={onCancel}>
Cancel
</Button>
</Stack>
</>
);
};
function getButtonVariant(
errors: FormFieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
) {
return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'destructive' : 'primary';
}
function getButtonText(
errors: FormFieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
) {
return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'Import (Overwrite)' : 'Import';
}
@@ -0,0 +1,181 @@
import { useState } from 'react';
import { locationUtil } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { AnnotationQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Form } from 'app/core/components/Form/Form';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { clearLoadedDashboard } from 'app/features/manage-dashboards/state/actions';
import { useDispatch, useSelector, StoreState } from 'app/types';
import { ImportDashboardFormV2 } from './ImportDashboardFormV2';
const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported';
export function ImportDashboardOverviewV2() {
const [uidReset, setUidReset] = useState(false);
const dispatch = useDispatch();
// Get state from Redux store
const searchObj = locationService.getSearchObject();
const dashboard = useSelector((state: StoreState) => state.importDashboard.dashboard as DashboardV2Spec);
const inputs = useSelector((state: StoreState) => state.importDashboard.inputs);
const folder = searchObj.folderUid ? { uid: String(searchObj.folderUid) } : { uid: '' };
function onUidReset() {
setUidReset(true);
}
function onCancel() {
dispatch(clearLoadedDashboard());
}
async function onSubmit(form: SaveDashboardCommand<DashboardV2Spec>) {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
const dashboardWithDataSources: DashboardV2Spec = {
...dashboard,
title: form.dashboard.title,
annotations: dashboard.annotations?.map((annotation: AnnotationQueryKind) => {
if (annotation.spec.datasource?.type) {
const dsType = annotation.spec.datasource.type;
if (form[`datasource-${dsType}` as keyof typeof form]) {
const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string };
return {
...annotation,
spec: {
...annotation.spec,
datasource: {
uid: ds.uid,
type: ds.type,
},
},
};
}
}
return annotation;
}),
variables: dashboard.variables?.map((variable) => {
if (variable.kind === 'QueryVariable') {
if (variable.spec.datasource?.type) {
const dsType = variable.spec.datasource.type;
if (form[`datasource-${dsType}` as keyof typeof form]) {
const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string };
return {
...variable,
spec: {
...variable.spec,
datasource: {
...variable.spec.datasource,
uid: ds.uid,
type: ds.type,
},
options: [],
current: {
text: '',
value: '',
},
refresh: 'onDashboardLoad',
},
};
}
}
} else if (variable.kind === 'DatasourceVariable') {
return {
...variable,
spec: {
...variable.spec,
current: {
text: '',
value: '',
},
},
};
}
return variable;
}),
elements: Object.fromEntries(
Object.entries(dashboard.elements).map(([key, element]) => {
if (element.kind === 'Panel') {
const panel = { ...element.spec };
if (panel.data?.kind === 'QueryGroup') {
const newQueries = panel.data.spec.queries.map((query: any) => {
if (query.kind === 'PanelQuery') {
const queryType = query.spec.query?.kind;
// Match datasource by query kind
if (queryType && form[`datasource-${queryType}` as keyof typeof form]) {
const ds = form[`datasource-${queryType}` as keyof typeof form] as { uid: string; type: string };
return {
...query,
spec: {
...query.spec,
datasource: {
uid: ds.uid,
type: ds.type,
},
},
};
}
}
return query;
});
panel.data = {
...panel.data,
spec: {
...panel.data.spec,
queries: newQueries,
},
};
}
return [
key,
{
kind: element.kind,
spec: panel,
},
];
}
return [key, element];
})
),
};
const result = await getDashboardAPI('v2').saveDashboard({
...form,
dashboard: dashboardWithDataSources,
});
if (result.url) {
const dashboardUrl = locationUtil.stripBaseFromUrl(result.url);
locationService.push(dashboardUrl);
}
}
return (
<>
<Form<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
onSubmit={onSubmit}
defaultValues={{ dashboard, k8s: { annotations: { 'grafana.app/folder': folder.uid } } }}
validateOnMount
validateOn="onChange"
>
{({ register, errors, control, watch, getValues }) => (
<ImportDashboardFormV2
register={register}
inputs={inputs}
errors={errors}
control={control}
getValues={getValues}
uidReset={uidReset}
onCancel={onCancel}
onUidReset={onUidReset}
onSubmit={onSubmit}
watch={watch}
/>
)}
</Form>
</>
);
}
@@ -27,12 +27,14 @@ import { Form } from 'app/core/components/Form/Form';
import { Page } from 'app/core/components/Page/Page';
import { t, Trans } from 'app/core/internationalization';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { dispatch } from 'app/store/store';
import { StoreState } from 'app/types';
import { cleanUpAction } from '../../core/actions/cleanUp';
import { ImportDashboardOverviewV2 } from '../dashboard-scene/v2schema/ImportDashboardOverviewV2';
import { ImportDashboardOverview } from './components/ImportDashboardOverview';
import { fetchGcomDashboard, importDashboardJson } from './state/actions';
import { fetchGcomDashboard, importDashboardJson, importDashboardV2Json } from './state/actions';
import { initialImportDashboardState } from './state/reducers';
import { validateDashboardJson, validateGcomDashboard } from './utils/validation';
@@ -53,6 +55,7 @@ const JSON_PLACEHOLDER = `{
const mapStateToProps = (state: StoreState) => ({
loadingState: state.importDashboard.state,
dashboard: state.importDashboard.dashboard,
});
const mapDispatchToProps = {
@@ -88,7 +91,13 @@ class UnthemedDashboardImport extends PureComponent<Props> {
});
try {
this.props.importDashboardJson(JSON.parse(String(result)));
const json = JSON.parse(String(result));
if (json.elements) {
dispatch(importDashboardV2Json(json));
return;
}
this.props.importDashboardJson(json);
} catch (error) {
if (error instanceof Error) {
appEvents.emit(AppEvents.alertError, ['Import failed', 'JSON -> JS Serialization failed: ' + error.message]);
@@ -102,7 +111,14 @@ class UnthemedDashboardImport extends PureComponent<Props> {
import_source: 'json_pasted',
});
this.props.importDashboardJson(JSON.parse(formData.dashboardJson));
const dashboard = JSON.parse(formData.dashboardJson);
if (dashboard.elements) {
dispatch(importDashboardV2Json(dashboard));
return;
}
this.props.importDashboardJson(dashboard);
};
getGcomDashboard = (formData: { gcomDashboard: string }) => {
@@ -229,6 +245,19 @@ class UnthemedDashboardImport extends PureComponent<Props> {
subTitle: 'Import dashboard from file or Grafana.com',
};
getDashboardOverview() {
const { loadingState, dashboard } = this.props;
if (loadingState === LoadingState.Done) {
if (dashboard.elements) {
return <ImportDashboardOverviewV2 />;
}
return <ImportDashboardOverview />;
}
return null;
}
render() {
const { loadingState } = this.props;
@@ -243,7 +272,7 @@ class UnthemedDashboardImport extends PureComponent<Props> {
</Stack>
)}
{[LoadingState.Error, LoadingState.NotStarted].includes(loadingState) && this.renderImportForm()}
{loadingState === LoadingState.Done && <ImportDashboardOverview />}
{this.getDashboardOverview()}
</Page.Contents>
</Page>
);
@@ -1,8 +1,10 @@
import { DataSourceInstanceSettings } from '@grafana/data';
import { getBackendSrv, getDataSourceSrv, isFetchError } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types';
import {
@@ -18,6 +20,7 @@ import { DashboardJson } from '../types';
import {
clearDashboard,
DataSourceInput,
fetchDashboard,
fetchFailed,
ImportDashboardDTO,
@@ -56,6 +59,13 @@ export function importDashboardJson(dashboard: any): ThunkResult<void> {
};
}
export function importDashboardV2Json(dashboard: DashboardV2Spec): ThunkResult<void> {
return async (dispatch) => {
dispatch(setJsonDashboard(dashboard));
dispatch(processV2Elements(dashboard));
};
}
const getNewLibraryPanelsByInput = (input: Input, state: ImportDashboardState): LibraryPanel[] | undefined => {
return input?.usage?.libraryPanels?.filter((usageLibPanel) =>
state.inputs.libraryPanels.some(
@@ -142,6 +152,53 @@ function processElements(dashboardJson?: { __elements?: Record<string, LibraryEl
};
}
function processV2Elements(dashboard: DashboardV2Spec): ThunkResult<void> {
return async function (dispatch) {
const elements = dashboard.elements;
// get elements from dashboard
// each element can only be a panel
const inputs: Record<string, DataSourceInput> = {};
for (const element of Object.values(elements)) {
if (element.kind !== 'Panel') {
throw new Error('Only panels are currenlty supported in v2 dashboards');
}
for (const query of element.spec.data.spec.queries) {
const datasourceRef = query.spec.datasource;
if (!datasourceRef) {
let dataSourceInput: DataSourceInput | undefined;
const dsType = query.spec.query.kind;
const datasource = await getDatasourceSrv().get({ type: dsType });
if (!datasource) {
dataSourceInput = {
name: dsType,
label: dsType,
info: `No data sources of type ${dsType} found`,
value: '',
type: InputType.DataSource,
pluginId: dsType,
};
inputs[dsType] = dataSourceInput;
} else {
dataSourceInput = {
name: datasource.name,
label: datasource.name,
info: `Select a ${datasource.name} data source`,
value: datasource.uid,
type: InputType.DataSource,
pluginId: datasource.meta?.id,
};
inputs[datasource.meta?.id] = dataSourceInput;
}
}
}
}
dispatch(setInputs(Object.values(inputs)));
};
}
export async function getLibraryPanelInputs(dashboardJson?: {
__elements?: Record<string, LibraryElementExport>;
}): Promise<LibraryPanelInput[]> {