diff --git a/.betterer.results b/.betterer.results index 917b0d0f1f2..518606f1515 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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 or use t()", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "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"], diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index 4e15cffeb3d..73faa5df734 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -64,7 +64,6 @@ LibraryPanelSpec: { id: number // Title for the library panel in the dashboard title: string - libraryPanel: LibraryPanelRef } diff --git a/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx b/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx new file mode 100644 index 00000000000..4df6d8faf52 --- /dev/null +++ b/public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx @@ -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 & { [key: `datasource-${string}`]: string }>, + 'register' | 'control' | 'getValues' | 'watch' + > { + inputs: DashboardInputs; + uidReset: boolean; + errors: FieldErrors & { [key: `datasource-${string}`]: string }>; + onCancel: () => void; + onUidReset: () => void; + onSubmit: FormsOnSubmit & { [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>({}); + /* + 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 ( + <> + Options + + await validateTitle(v, getValues().folderUid ?? ''), + })} + type="text" + data-testid={selectors.components.ImportDashboardForm.name} + /> + + + + render={({ field: { ref, value, onChange, ...field } }) => ( + { + onChange(uid, title); + }} + value={value} + /> + )} + name="folderUid" + control={control} + /> + + {inputs.dataSources && + inputs.dataSources.map((input: DataSourceInput) => { + if (input.pluginId === ExpressionDatasourceRef.type) { + return null; + } + + const dataSourceOption = `datasource-${input.pluginId}` as const; + + return ( + + + name={dataSourceOption} + render={({ field: { ref, ...field } }) => ( + { + field.onChange(ds); + // Update our selected datasources map + setSelectedDataSources((prev) => ({ + ...prev, + [input.pluginId]: { + uid: ds.uid, + type: ds.type, + }, + })); + }} + /> + )} + control={control} + rules={{ required: true }} + /> + + ); + })} + + + + + + + ); +}; + +function getButtonVariant( + errors: FormFieldErrors & { [key: `datasource-${string}`]: string }> +) { + return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'destructive' : 'primary'; +} + +function getButtonText( + errors: FormFieldErrors & { [key: `datasource-${string}`]: string }> +) { + return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'Import (Overwrite)' : 'Import'; +} diff --git a/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx b/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx new file mode 100644 index 00000000000..ef0262adec5 --- /dev/null +++ b/public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx @@ -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) { + 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 ( + <> + & { [key: `datasource-${string}`]: string }> + onSubmit={onSubmit} + defaultValues={{ dashboard, k8s: { annotations: { 'grafana.app/folder': folder.uid } } }} + validateOnMount + validateOn="onChange" + > + {({ register, errors, control, watch, getValues }) => ( + + )} + + + ); +} diff --git a/public/app/features/manage-dashboards/DashboardImportPage.tsx b/public/app/features/manage-dashboards/DashboardImportPage.tsx index bfebb0cec81..43297d1ec37 100644 --- a/public/app/features/manage-dashboards/DashboardImportPage.tsx +++ b/public/app/features/manage-dashboards/DashboardImportPage.tsx @@ -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 { }); 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 { 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 { subTitle: 'Import dashboard from file or Grafana.com', }; + getDashboardOverview() { + const { loadingState, dashboard } = this.props; + + if (loadingState === LoadingState.Done) { + if (dashboard.elements) { + return ; + } + return ; + } + + return null; + } + render() { const { loadingState } = this.props; @@ -243,7 +272,7 @@ class UnthemedDashboardImport extends PureComponent { )} {[LoadingState.Error, LoadingState.NotStarted].includes(loadingState) && this.renderImportForm()} - {loadingState === LoadingState.Done && } + {this.getDashboardOverview()} ); diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 48a8462a7f9..a5ddf74bbc6 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -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 { }; } +export function importDashboardV2Json(dashboard: DashboardV2Spec): ThunkResult { + 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 { + return async function (dispatch) { + const elements = dashboard.elements; + // get elements from dashboard + // each element can only be a panel + const inputs: Record = {}; + 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; }): Promise {