Refactor dashboard import to separate k8s and legacy paths (#116482)

* when importing v1 dashboard, use POST

* fix es-lint

* cleanup

* Isolate legacy import api usage

* Update translations

* Improve type detection

* Refactor types and utils to avoid duplication and isolate them in legacy when needed

* Use types from manage-dashboard since it is used from other files out of import

* Avoid regressions

* Fix linting and tests

* Improve types

* Consilidate api utils checkers

* Split ImportOverview in two versions

* simplify utils

* Fix tests

* Rename to ExportFormat

* linter

* refactor tests

* add gap

* add gap on ImportForm

* Update public/app/core/utils/isRecord.ts

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* clean up

* fix layout

* fix mock

* update kind when exporting to be DashboardWithAccessInfo

* remove type assertions; don't process built in annotations

* update E2Es to use correct dashboard kind

* update export test

* another e2e

* fix v2 dash for e2e

* check for "Dashboard" when checking for a resource

* update test

---------

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Ivan Ortega Alba 2026-02-02 15:14:01 +01:00 committed by GitHub
parent d8e5e03b7d
commit 381cc6555d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3109 additions and 1636 deletions

1
.github/CODEOWNERS vendored
View file

@ -915,6 +915,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
/public/app/core/utils/fetch* @grafana/grafana-frontend-platform
/public/app/core/utils/flatten.ts @grafana/grafana-frontend-platform
/public/app/core/utils/isShallowEqual.ts @grafana/grafana-frontend-platform
/public/app/core/utils/isRecord.ts @grafana/dashboards-squad
/public/app/core/utils/kbn* @grafana/grafana-frontend-platform
/public/app/core/utils/navBarItem-translations.ts @grafana/grafana-search-navigate-organise
/public/app/core/utils/object* @grafana/grafana-frontend-platform

View file

@ -1,6 +1,6 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"kind": "DashboardWithAccessInfo",
"metadata": {
"name": "adtbh3z",
"namespace": "default",

View file

@ -1,6 +1,6 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"kind": "DashboardWithAccessInfo",
"metadata": {
"name": "adtbg2z",
"namespace": "default",

View file

@ -1,6 +1,6 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"kind": "DashboardWithAccessInfo",
"metadata": {
"name": "addfpww",
"namespace": "default",

View file

@ -1,6 +1,6 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"kind": "DashboardWithAccessInfo",
"metadata": {
"name": "adbb8vn",
"namespace": "default",

View file

@ -1,6 +1,6 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"kind": "DashboardWithAccessInfo",
"metadata": {
"name": "ad8l8fz",
"namespace": "default",
@ -56,8 +56,11 @@
"spec": {
"hidden": false,
"query": {
"group": "",
"group": "grafana-testdata-datasource",
"kind": "DataQuery",
"datasource": {
"name": "gdev-testdata"
},
"spec": {},
"version": "v0"
},
@ -162,7 +165,10 @@
"spec": {
"hidden": false,
"query": {
"group": "",
"group": "grafana-testdata-datasource",
"datasource": {
"name": "gdev-testdata"
},
"kind": "DataQuery",
"spec": {},
"version": "v0"

View file

@ -1,6 +1,6 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"kind": "DashboardWithAccessInfo",
"metadata": {
"name": "addwm76",
"namespace": "default",

View file

@ -1,6 +1,6 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"kind": "DashboardWithAccessInfo",
"metadata": {
"name": "fa400625-2a44-4add-a369-e6c972eb4bd6",
"generation": 1,

View file

@ -1889,11 +1889,6 @@
"count": 2
}
},
"public/app/features/dashboard-scene/sharing/ShareExportTab.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx": {
"no-restricted-syntax": {
"count": 4
@ -1919,19 +1914,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
},
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 3
}
},
"public/app/features/dashboard/api/ResponseTransformers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@ -2653,48 +2635,6 @@
"count": 1
}
},
"public/app/features/manage-dashboards/DashboardImportPage.tsx": {
"no-restricted-syntax": {
"count": 2
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/manage-dashboards/components/ImportDashboardForm.tsx": {
"no-restricted-syntax": {
"count": 5
}
},
"public/app/features/manage-dashboards/components/ImportDashboardLibraryPanelsList.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
},
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/manage-dashboards/state/actions.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
},
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"public/app/features/manage-dashboards/state/reducers.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
},
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/CreateTokenModal.tsx": {
"no-restricted-syntax": {
"count": 1

View file

@ -16,7 +16,7 @@ import dashboardReducers from 'app/features/dashboard/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import exploreReducers from 'app/features/explore/state/main';
import invitesReducers from 'app/features/invites/state/reducers';
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
import importDashboardReducers from 'app/features/manage-dashboards/import/legacy/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import panelsReducers from 'app/features/panel/state/reducers';
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';

View file

@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string | number | symbol, unknown> {
return typeof value === 'object' && value !== null;
}

View file

@ -86,11 +86,6 @@ jest.mock('app/features/playlist/PlaylistSrv', () => ({
},
}));
jest.mock('app/features/manage-dashboards/state/actions', () => ({
...jest.requireActual('app/features/manage-dashboards/state/actions'),
deleteDashboard: jest.fn().mockResolvedValue({}),
}));
locationUtil.initialize({
config: { appSubUrl: '/subUrl' } as GrafanaConfig,
getVariablesUrlParams: jest.fn(),

View file

@ -11,11 +11,12 @@ import { SceneComponentProps } from '@grafana/scenes';
import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { notifyApp } from 'app/core/reducers/appNotification';
import { ExportFormat } from 'app/features/dashboard/api/types';
import { dispatch } from 'app/store/store';
import { ShareExportTab } from '../ShareExportTab';
import { ExportMode, ResourceExport } from './ResourceExport';
import { ResourceExport } from './ResourceExport';
const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson;
@ -33,13 +34,13 @@ export class ExportAsCode extends ShareExportTab {
function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
const styles = useStyles2(getStyles);
const { isSharingExternally, isViewingYAML, exportMode } = model.useState();
const { isSharingExternally, isViewingYAML, exportFormat } = model.useState();
const dashboardJson = useAsync(async () => {
const json = await model.getExportableDashboardJson();
return json;
}, [isSharingExternally, exportMode]);
}, [isSharingExternally, exportFormat]);
const stringifiedDashboardJson = JSON.stringify(dashboardJson.value?.json, null, 2);
const stringifiedDashboardYAML = yaml.dump(dashboardJson.value?.json, {
@ -61,9 +62,9 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
<ResourceExport
dashboardJson={dashboardJson}
isSharingExternally={isSharingExternally ?? false}
exportMode={exportMode ?? ExportMode.Classic}
exportFormat={exportFormat ?? ExportFormat.Classic}
isViewingYAML={isViewingYAML ?? false}
onExportModeChange={model.onExportModeChange}
onExportFormatChange={model.onExportFormatChange}
onShareExternallyChange={model.onShareExternallyChange}
onViewYAML={model.onViewYAML}
/>

View file

@ -5,8 +5,9 @@ import { AsyncState } from 'react-use/lib/useAsync';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { ExportFormat } from 'app/features/dashboard/api/types';
import { ExportMode, ResourceExport } from './ResourceExport';
import { ResourceExport } from './ResourceExport';
type DashboardJsonState = AsyncState<{
json: Dashboard | DashboardV2Spec | { error: unknown };
@ -27,9 +28,9 @@ const createDefaultProps = (overrides?: Partial<Parameters<typeof ResourceExport
},
} as DashboardJsonState,
isSharingExternally: false,
exportMode: ExportMode.Classic,
exportFormat: ExportFormat.Classic,
isViewingYAML: false,
onExportModeChange: jest.fn(),
onExportFormatChange: jest.fn(),
onShareExternallyChange: jest.fn(),
onViewYAML: jest.fn(),
};
@ -72,7 +73,7 @@ describe('ResourceExport', () => {
});
it('should have first option selected by default when exportMode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
render(<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.Classic })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
@ -80,15 +81,15 @@ describe('ResourceExport', () => {
expect(radios[0]).toBeChecked();
});
it('should call onExportModeChange when export mode is changed', async () => {
const onExportModeChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ onExportModeChange })} />);
it('should call onExportFormatChange when export mode is changed', async () => {
const onExportFormatChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ onExportFormatChange })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const radios = within(radioGroup).getAllByRole('radio');
await userEvent.click(radios[1]); // V1 Resource
expect(onExportModeChange).toHaveBeenCalledWith(ExportMode.V1Resource);
expect(onExportFormatChange).toHaveBeenCalledWith(ExportFormat.V1Resource);
});
});
@ -103,17 +104,17 @@ describe('ResourceExport', () => {
describe('format options', () => {
it('should not show format options when export mode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
render(<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.Classic })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
expect(screen.queryByRole('radiogroup', { name: /format/i })).not.toBeInTheDocument();
});
it.each([ExportMode.V1Resource, ExportMode.V2Resource])(
it.each([ExportFormat.V1Resource, ExportFormat.V2Resource])(
'should show format options when export mode is %s',
async (exportMode) => {
render(<ResourceExport {...createDefaultProps({ exportMode })} />);
async (exportFormat) => {
render(<ResourceExport {...createDefaultProps({ exportFormat })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
@ -122,7 +123,9 @@ describe('ResourceExport', () => {
);
it('should have first format option selected when isViewingYAML is false', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: false })} />);
render(
<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.V1Resource, isViewingYAML: false })} />
);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
@ -131,7 +134,9 @@ describe('ResourceExport', () => {
});
it('should have second format option selected when isViewingYAML is true', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: true })} />);
render(
<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.V1Resource, isViewingYAML: true })} />
);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
@ -141,7 +146,7 @@ describe('ResourceExport', () => {
it('should call onViewYAML when format is changed', async () => {
const onViewYAML = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, onViewYAML })} />);
render(<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.V1Resource, onViewYAML })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
@ -153,7 +158,7 @@ describe('ResourceExport', () => {
describe('share externally switch', () => {
it('should show share externally switch for Classic mode', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
render(<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.Classic })} />);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument();
});
@ -163,7 +168,7 @@ describe('ResourceExport', () => {
<ResourceExport
{...createDefaultProps({
dashboardJson: createV2DashboardJson(),
exportMode: ExportMode.V2Resource,
exportFormat: ExportFormat.V2Resource,
})}
/>
);
@ -173,7 +178,9 @@ describe('ResourceExport', () => {
it('should call onShareExternallyChange when switch is toggled', async () => {
const onShareExternallyChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, onShareExternallyChange })} />);
render(
<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.Classic, onShareExternallyChange })} />
);
const switchElement = screen.getByTestId(selector.exportExternallyToggle);
await userEvent.click(switchElement);
@ -181,7 +188,9 @@ describe('ResourceExport', () => {
});
it('should reflect isSharingExternally value in switch', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, isSharingExternally: true })} />);
render(
<ResourceExport {...createDefaultProps({ exportFormat: ExportFormat.Classic, isSharingExternally: true })} />
);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeChecked();
});

View file

@ -6,16 +6,11 @@ import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Alert, Icon, Label, RadioButtonGroup, Stack, Switch, Box, Tooltip } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { ExportFormat } from 'app/features/dashboard/api/types';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { ExportableResource } from '../ShareExportTab';
export enum ExportMode {
Classic = 'classic',
V1Resource = 'v1-resource',
V2Resource = 'v2-resource',
}
interface Props {
dashboardJson: AsyncState<{
json: Dashboard | DashboardJson | DashboardV2Spec | ExportableResource | { error: unknown };
@ -23,9 +18,9 @@ interface Props {
initialSaveModelVersion: 'v1' | 'v2';
}>;
isSharingExternally: boolean;
exportMode: ExportMode;
exportFormat: ExportFormat;
isViewingYAML: boolean;
onExportModeChange: (mode: ExportMode) => void;
onExportFormatChange: (format: ExportFormat) => void;
onShareExternallyChange: () => void;
onViewYAML: () => void;
}
@ -35,9 +30,9 @@ const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson;
export function ResourceExport({
dashboardJson,
isSharingExternally,
exportMode,
exportFormat,
isViewingYAML,
onExportModeChange,
onExportFormatChange,
onShareExternallyChange,
onViewYAML,
}: Props) {
@ -48,7 +43,7 @@ export function ResourceExport({
const showV2LibPanelAlert = isV2Dashboard && isSharingExternally && hasLibraryPanels;
const switchExportLabel =
exportMode === ExportMode.V2Resource
exportFormat === ExportFormat.V2Resource
? t('dashboard-scene.resource-export.share-externally', 'Share dashboard with another instance')
: t('share-modal.export.share-externally-label', 'Export for sharing externally');
const switchExportTooltip = t(
@ -61,15 +56,15 @@ export function ResourceExport({
const exportResourceOptions = [
{
label: t('dashboard-scene.resource-export.label.classic', 'Classic'),
value: ExportMode.Classic,
value: ExportFormat.Classic,
},
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
value: ExportFormat.V1Resource,
},
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
value: ExportFormat.V2Resource,
},
];
@ -88,14 +83,14 @@ export function ResourceExport({
<Label>{switchExportModeLabel}</Label>
<RadioButtonGroup
options={exportResourceOptions}
value={exportMode}
onChange={(value) => onExportModeChange(value)}
value={exportFormat}
onChange={(value) => onExportFormatChange(value)}
aria-label={switchExportModeLabel}
/>
</Stack>
)}
{exportMode !== ExportMode.Classic && (
{exportFormat !== ExportFormat.Classic && (
<Stack gap={1} alignItems="center">
<Label>{switchExportFormatLabel}</Label>
<RadioButtonGroup
@ -114,8 +109,8 @@ export function ResourceExport({
</QueryOperationRow>
{(isV2Dashboard ||
exportMode === ExportMode.Classic ||
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
exportFormat === ExportFormat.Classic ||
(initialSaveModelVersion === 'v2' && exportFormat === ExportFormat.V1Resource)) && (
<Stack gap={1} alignItems="start">
<Label>
<Stack gap={0.5} alignItems="center">

View file

@ -7,6 +7,7 @@ import {
defaultVizConfigSpec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import * as ResponseTransformers from 'app/features/dashboard/api/ResponseTransformers';
import { ExportFormat } from 'app/features/dashboard/api/types';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { DashboardDataDTO } from 'app/types/dashboard';
@ -16,7 +17,6 @@ import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLay
import * as sceneToV1 from '../serialization/transformSceneToSaveModel';
import * as sceneToV2 from '../serialization/transformSceneToSaveModelSchemaV2';
import { ExportMode } from './ExportButton/ResourceExport';
import { ShareExportTab } from './ShareExportTab';
describe('ShareExportTab', () => {
@ -99,14 +99,14 @@ describe('ShareExportTab', () => {
// If V1 dashboard → V1 Resource should export with V1 apiVersion
it('should export V1 dashboard as V1 resource with correct apiVersion', async () => {
const tab = buildV1DashboardScenario();
tab.setState({ exportMode: ExportMode.V1Resource });
tab.setState({ exportFormat: ExportFormat.V1Resource });
const result = await tab.getExportableDashboardJson();
// Should use V1 API version
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
status: {},
});
@ -122,14 +122,14 @@ describe('ShareExportTab', () => {
it('should auto-transform V2 dashboard to V1 resource with correct apiVersion', async () => {
const tab = buildV2DashboardScenario();
// user selects V1Resource even though is V2 dashboard
tab.setState({ exportMode: ExportMode.V1Resource });
tab.setState({ exportFormat: ExportFormat.V1Resource });
const result = await tab.getExportableDashboardJson();
// Should use V1 API version (not V2!)
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
status: {},
});
@ -145,7 +145,7 @@ describe('ShareExportTab', () => {
it('should handle external sharing when transforming V2 to V1', async () => {
const tab = buildV2DashboardScenario();
tab.setState({
exportMode: ExportMode.V1Resource,
exportFormat: ExportFormat.V1Resource,
isSharingExternally: true,
});
@ -154,7 +154,7 @@ describe('ShareExportTab', () => {
// Should use V1 API version
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
status: {},
});
@ -174,14 +174,14 @@ describe('ShareExportTab', () => {
// If V2 dashboard → V2 Resource should export with V2 apiVersion
it('should export V2 dashboard as V2 resource with correct apiVersion', async () => {
const tab = buildV2DashboardScenario();
tab.setState({ exportMode: ExportMode.V2Resource });
tab.setState({ exportFormat: ExportFormat.V2Resource });
const result = await tab.getExportableDashboardJson();
// Should use V2 API version
expect(result.json).toMatchObject({
apiVersion: 'dashboard.grafana.app/v2beta1',
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
status: {},
});
@ -195,7 +195,7 @@ describe('ShareExportTab', () => {
// If V1 dashboard → V2 Resource should detect library panels correctly
it('should detect library panels in V1 dashboard when exporting as V2 resource', async () => {
const tab = buildV1DashboardWithLibraryPanels();
tab.setState({ exportMode: ExportMode.V2Resource });
tab.setState({ exportFormat: ExportFormat.V2Resource });
const result = await tab.getExportableDashboardJson();
@ -207,7 +207,7 @@ describe('ShareExportTab', () => {
// If V1 dashboard with dashboardNewLayouts disabled → V2 Resource should detect library panels correctly
it('should detect library panels in V1 dashboard when user selects V2Resource export mode', async () => {
const tab = buildV1DashboardWithLibraryPanels();
tab.setState({ exportMode: ExportMode.V2Resource });
tab.setState({ exportFormat: ExportFormat.V2Resource });
const result = await tab.getExportableDashboardJson();
@ -219,7 +219,7 @@ describe('ShareExportTab', () => {
// If V1 dashboard without library panels → V2 Resource should return false
it('should return false for hasLibraryPanels when V1 dashboard has no library panels', async () => {
const tab = buildV1DashboardScenario();
tab.setState({ exportMode: ExportMode.V2Resource });
tab.setState({ exportFormat: ExportFormat.V2Resource });
const result = await tab.getExportableDashboardJson();
@ -241,7 +241,7 @@ describe('ShareExportTab', () => {
// If V2 dashboard → V2 Resource should detect library panels correctly
it('should detect library panels in V2 dashboard when exporting as V2 resource', async () => {
const tab = buildV2DashboardWithLibraryPanels();
tab.setState({ exportMode: ExportMode.V2Resource });
tab.setState({ exportFormat: ExportFormat.V2Resource });
const result = await tab.getExportableDashboardJson();
@ -253,7 +253,7 @@ describe('ShareExportTab', () => {
// Test the second branch: V2 dashboard with V1 initial save model
it('should detect library panels in V2 dashboard with V1 initial save model', async () => {
const tab = buildV2DashboardWithV1InitialSaveModel();
tab.setState({ exportMode: ExportMode.V2Resource });
tab.setState({ exportFormat: ExportFormat.V2Resource });
const result = await tab.getExportableDashboardJson();
@ -265,7 +265,7 @@ describe('ShareExportTab', () => {
// If V2 dashboard without library panels → V2 Resource should return false
it('should return false for hasLibraryPanels when V2 dashboard has no library panels', async () => {
const tab = buildV2DashboardScenario();
tab.setState({ exportMode: ExportMode.V2Resource });
tab.setState({ exportFormat: ExportFormat.V2Resource });
const result = await tab.getExportableDashboardJson();
@ -279,7 +279,7 @@ describe('ShareExportTab', () => {
// If V1 dashboard → Classic should export plain dashboard JSON
it('should export V1 dashboard in classic format', async () => {
const tab = buildV1DashboardScenario();
tab.setState({ exportMode: ExportMode.Classic });
tab.setState({ exportFormat: ExportFormat.Classic });
const result = await tab.getExportableDashboardJson();
@ -310,7 +310,7 @@ describe('ShareExportTab', () => {
expect(tab.state.isViewingYAML).toBe(true);
// Switch to Classic mode
tab.onExportModeChange(ExportMode.Classic);
tab.onExportFormatChange(ExportFormat.Classic);
// Should disable YAML viewing
expect(tab.state.isViewingYAML).toBe(false);
@ -325,11 +325,11 @@ describe('ShareExportTab', () => {
expect(tab.state.isViewingYAML).toBe(true);
// Switch to V1Resource mode
tab.onExportModeChange(ExportMode.V1Resource);
tab.onExportFormatChange(ExportFormat.V1Resource);
expect(tab.state.isViewingYAML).toBe(true); // Should preserve
// Switch to V2Resource mode
tab.onExportModeChange(ExportMode.V2Resource);
tab.onExportFormatChange(ExportFormat.V2Resource);
expect(tab.state.isViewingYAML).toBe(true); // Should preserve
});
});

View file

@ -12,7 +12,7 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from '@grafana/ui';
import { ObjectMeta } from 'app/features/apiserver/types';
import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { ExportFormat, DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec, isV1ClassicDashboard } from 'app/features/dashboard/api/utils';
import { K8S_V1_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v1';
import { K8S_V2_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v2';
@ -30,12 +30,12 @@ import { getVariablesCompatibility } from '../utils/getVariablesCompatibility';
import { DashboardInteractions } from '../utils/interactions';
import { getDashboardSceneFor, hasLibraryPanelsInV1Dashboard } from '../utils/utils';
import { ExportMode, ResourceExport } from './ExportButton/ResourceExport';
import { ResourceExport } from './ExportButton/ResourceExport';
import { SceneShareTabState, ShareView } from './types';
export interface ExportableResource {
apiVersion: string;
kind: 'Dashboard';
kind: 'DashboardWithAccessInfo';
metadata: DashboardWithAccessInfo<DashboardV2Spec>['metadata'] | Partial<ObjectMeta>;
spec: Dashboard | DashboardModel | DashboardV2Spec | DashboardJson | DashboardDataDTO | { error: unknown };
// A placeholder for now because as code tooling expects it
@ -46,7 +46,7 @@ export interface ShareExportTabState extends SceneShareTabState {
isSharingExternally?: boolean;
isViewingJSON?: boolean;
isViewingYAML?: boolean;
exportMode?: ExportMode;
exportFormat?: ExportFormat;
}
export class ShareExportTab extends SceneObjectBase<ShareExportTabState> implements ShareView {
@ -58,7 +58,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
...state,
isSharingExternally: false,
isViewingJSON: false,
exportMode: config.featureToggles.kubernetesDashboards ? ExportMode.Classic : undefined,
exportFormat: config.featureToggles.kubernetesDashboards ? ExportFormat.Classic : undefined,
});
}
@ -76,12 +76,12 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
});
};
public onExportModeChange = (exportMode: ExportMode) => {
public onExportFormatChange = (exportFormat: ExportFormat) => {
this.setState({
exportMode,
exportFormat,
});
if (exportMode === ExportMode.Classic) {
if (exportFormat === ExportFormat.Classic) {
this.setState({
isViewingYAML: false,
});
@ -109,7 +109,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}> => {
const { isSharingExternally, exportMode } = this.state;
const { isSharingExternally, exportFormat } = this.state;
const scene = getDashboardSceneFor(this);
const exportableDashboard = await scene.serializer.makeExportableExternally(scene);
@ -123,10 +123,10 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
isDashboardV2Spec(origDashboard) &&
'elements' in exportable &&
initialSaveModelVersion === 'v2' &&
exportMode !== ExportMode.V1Resource
exportFormat !== ExportFormat.V1Resource
) {
this.setState({
exportMode: ExportMode.V2Resource,
exportFormat: ExportFormat.V2Resource,
});
// For automatic V2 path, also process library panels when sharing externally
@ -147,7 +147,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
return {
json: {
apiVersion: scene.serializer.apiVersion ?? '',
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
metadata,
spec: finalSpec,
status: {},
@ -157,7 +157,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
};
}
if (exportMode === ExportMode.V1Resource) {
if (exportFormat === ExportFormat.V1Resource) {
// Check if source is V2 and auto-transform to V1
if (isDashboardV2Spec(origDashboard) && initialSaveModelVersion === 'v2') {
try {
@ -185,7 +185,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
json: {
// Forcing V1 version here to match export mode selection
apiVersion: `${K8S_V1_DASHBOARD_API_CONFIG.group}/${K8S_V1_DASHBOARD_API_CONFIG.version}`,
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
metadata,
spec: exportableV1,
status: {},
@ -209,7 +209,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
json: {
// Forcing V1 version here to match export mode selection
apiVersion: `${K8S_V1_DASHBOARD_API_CONFIG.group}/${K8S_V1_DASHBOARD_API_CONFIG.version}`,
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
metadata,
spec,
status: {},
@ -220,7 +220,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
}
}
if (exportMode === ExportMode.V2Resource) {
if (exportFormat === ExportFormat.V2Resource) {
let sceneForV2Export = scene;
// When exporting v1 dashboard as v2, we need to recreate the scene with v2 layout creator
@ -259,7 +259,7 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
json: {
// Forcing V2 version here because in this case we have v1 serializer
apiVersion: `${K8S_V2_DASHBOARD_API_CONFIG.group}/${K8S_V2_DASHBOARD_API_CONFIG.version}`,
kind: 'Dashboard',
kind: 'DashboardWithAccessInfo',
metadata,
spec: exportableV2,
status: {},
@ -333,13 +333,13 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
public onClipboardCopy = async () => {
const dashboard = await this.getExportableDashboardJson();
const { isSharingExternally, isViewingYAML, exportMode } = this.state;
const { isSharingExternally, isViewingYAML, exportFormat } = this.state;
DashboardInteractions.exportCopyJsonClicked({
externally: isSharingExternally,
dashboard_schema_version: dashboard.initialSaveModelVersion,
has_library_panels: Boolean(dashboard.hasLibraryPanels),
export_mode: exportMode || 'classic',
export_mode: exportFormat || ExportFormat.Classic,
format: isViewingYAML ? 'yaml' : 'json',
action: 'copy',
});
@ -393,12 +393,12 @@ function getMetadata(
}
function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>) {
const { isSharingExternally, isViewingJSON, modalRef, exportMode, isViewingYAML } = model.useState();
const { isSharingExternally, isViewingJSON, modalRef, exportFormat, isViewingYAML } = model.useState();
const dashboardJson = useAsync(async () => {
const json = await model.getExportableDashboardJson();
return json;
}, [isViewingJSON, isSharingExternally, exportMode]);
}, [isViewingJSON, isSharingExternally, exportFormat]);
const stringifiedDashboardJson = JSON.stringify(dashboardJson.value?.json, null, 2);
const stringifiedDashboardYAML = yaml.dump(dashboardJson.value?.json, {
@ -419,15 +419,15 @@ function ShareExportTabRenderer({ model }: SceneComponentProps<ShareExportTab>)
<ResourceExport
dashboardJson={dashboardJson}
isSharingExternally={isSharingExternally ?? false}
exportMode={exportMode ?? ExportMode.Classic}
exportFormat={exportFormat ?? ExportFormat.Classic}
isViewingYAML={isViewingYAML ?? false}
onExportModeChange={model.onExportModeChange}
onExportFormatChange={model.onExportFormatChange}
onShareExternallyChange={model.onShareExternallyChange}
onViewYAML={model.onViewYAML}
/>
) : (
<Stack gap={2} direction="column">
<Field label={exportExternallyTranslation}>
<Field label={exportExternallyTranslation} noMargin>
<Switch
id="share-externally-toggle"
value={isSharingExternally}

View file

@ -1,83 +0,0 @@
import { locationUtil } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
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/store';
import { ImportDashboardFormV2 } from './ImportDashboardFormV2';
import { replaceDatasourcesInDashboard, DatasourceMappings } from './importDatasourceReplacer';
const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported';
type FormData = SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string };
export function ImportDashboardOverviewV2() {
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 onCancel() {
dispatch(clearLoadedDashboard());
}
async function onSubmit(form: FormData) {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
const mappings: DatasourceMappings = {};
for (const key of Object.keys(form)) {
if (key.startsWith('datasource-')) {
const dsType = key.replace('datasource-', '');
const ds = form[key as keyof typeof form] as { uid: string; type: string; name?: string } | undefined;
if (ds?.uid) {
mappings[dsType] = { uid: ds.uid, type: ds.type, name: ds.name };
}
}
}
const dashboardWithDataSources: DashboardV2Spec = {
...replaceDatasourcesInDashboard(dashboard, mappings),
title: form.dashboard.title,
};
const result = await getDashboardAPI('v2').saveDashboard({
...form,
dashboard: dashboardWithDataSources,
});
if (result.url) {
const dashboardUrl = locationUtil.stripBaseFromUrl(result.url);
locationService.push(dashboardUrl);
}
}
return (
<>
<Form<FormData>
onSubmit={onSubmit}
defaultValues={{ dashboard, folderUid: folder.uid, 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}
onCancel={onCancel}
onSubmit={onSubmit}
watch={watch}
/>
)}
</Form>
</>
);
}

View file

@ -1,263 +0,0 @@
import {
Spec as DashboardV2Spec,
defaultSpec,
defaultPanelSpec,
defaultDataQueryKind,
defaultPanelQuerySpec,
defaultVizConfigKind,
defaultQueryGroupSpec,
defaultQueryVariableSpec,
defaultDatasourceVariableSpec,
defaultAdhocVariableSpec,
defaultGroupByVariableSpec,
defaultAnnotationQuerySpec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { isVariableRef, replaceDatasourcesInDashboard, DatasourceMappings } from './importDatasourceReplacer';
describe('isVariableRef', () => {
it.each([
{ input: '${ds}', expected: true },
{ input: '$ds', expected: true },
{ input: 'abc123', expected: false },
{ input: undefined, expected: false },
{ input: '', expected: false },
])('returns $expected for $input', ({ input, expected }) => {
expect(isVariableRef(input)).toBe(expected);
});
});
describe('replaceDatasourcesInDashboard', () => {
const baseDashboard = defaultSpec();
const mappings: DatasourceMappings = {
loki: { uid: 'new-loki-uid', type: 'loki', name: 'New Loki' },
prometheus: { uid: 'new-prom-uid', type: 'prometheus', name: 'New Prometheus' },
};
const createPanelWithQuery = (group: string, datasourceName: string) => ({
kind: 'Panel' as const,
spec: {
...defaultPanelSpec(),
vizConfig: { ...defaultVizConfigKind(), kind: 'VizConfig' as const },
data: {
kind: 'QueryGroup' as const,
spec: {
...defaultQueryGroupSpec(),
queries: [
{
kind: 'PanelQuery' as const,
spec: {
...defaultPanelQuerySpec(),
query: {
...defaultDataQueryKind(),
kind: 'DataQuery' as const,
group,
datasource: { name: datasourceName },
},
},
},
],
},
},
},
});
const getPanelQueryDatasourceName = (result: DashboardV2Spec, panelKey = 'panel-1') => {
const panel = result.elements[panelKey];
if (panel.kind === 'Panel' && panel.spec.data?.kind === 'QueryGroup') {
return panel.spec.data.spec.queries[0].spec.query?.datasource?.name;
}
return undefined;
};
const getQueryVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'QueryVariable' ? variable : undefined;
};
const getDatasourceVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'DatasourceVariable' ? variable : undefined;
};
const getAdhocVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'AdhocVariable' ? variable : undefined;
};
const getGroupByVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'GroupByVariable' ? variable : undefined;
};
describe('panel queries', () => {
it.each([
{ group: 'loki', inputDs: 'old-loki-uid', expectedDs: 'new-loki-uid', desc: 'replaces hardcoded datasource' },
{ group: 'prometheus', inputDs: '${ds}', expectedDs: '${ds}', desc: 'preserves ${ds} variable reference' },
{ group: 'prometheus', inputDs: '$ds', expectedDs: '$ds', desc: 'preserves $ds variable reference' },
{
group: 'elasticsearch',
inputDs: 'es-uid',
expectedDs: 'es-uid',
desc: 'keeps original when no mapping exists',
},
])('$desc', ({ group, inputDs, expectedDs }) => {
const dashboard: DashboardV2Spec = {
...baseDashboard,
elements: { 'panel-1': createPanelWithQuery(group, inputDs) },
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
expect(getPanelQueryDatasourceName(result)).toBe(expectedDs);
});
});
describe('annotations', () => {
const createAnnotation = (group: string, datasourceName: string) => ({
kind: 'AnnotationQuery' as const,
spec: {
...defaultAnnotationQuerySpec(),
name: 'Test Annotation',
query: { ...defaultDataQueryKind(), kind: 'DataQuery' as const, group, datasource: { name: datasourceName } },
},
});
it.each([
{ inputDs: 'old-prom-uid', expectedDs: 'new-prom-uid', desc: 'replaces hardcoded datasource' },
{ inputDs: '${ds}', expectedDs: '${ds}', desc: 'preserves variable reference' },
])('$desc', ({ inputDs, expectedDs }) => {
const dashboard: DashboardV2Spec = {
...baseDashboard,
annotations: [createAnnotation('prometheus', inputDs)],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
expect(result.annotations?.[0].spec.query?.datasource?.name).toBe(expectedDs);
});
});
describe('query variable', () => {
const createQueryVariable = (group: string, datasourceName: string) => ({
kind: 'QueryVariable' as const,
spec: {
...defaultQueryVariableSpec(),
name: 'test_var',
query: { ...defaultDataQueryKind(), kind: 'DataQuery' as const, group, datasource: { name: datasourceName } },
},
});
it('replaces hardcoded datasource and resets options/current', () => {
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createQueryVariable('prometheus', 'old-prom-uid')],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getQueryVariable(result);
expect(variable).toBeDefined();
expect(variable?.spec.query?.datasource?.name).toBe('new-prom-uid');
expect(variable?.spec.options).toEqual([]);
expect(variable?.spec.current).toEqual({ text: '', value: '' });
expect(variable?.spec.refresh).toBe('onDashboardLoad');
});
it('preserves variable reference and keeps options intact', () => {
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createQueryVariable('prometheus', '${ds}')],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getQueryVariable(result);
expect(variable?.spec.query?.datasource?.name).toBe('${ds}');
expect(variable?.spec.options).toEqual([]);
});
});
describe('datasource variable', () => {
const createDatasourceVariable = (pluginId: string, currentValue: string, currentText: string) => ({
kind: 'DatasourceVariable' as const,
spec: {
...defaultDatasourceVariableSpec(),
name: 'ds',
pluginId,
current: { text: currentText, value: currentValue },
},
});
it('replaces current value in DatasourceVariable', () => {
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createDatasourceVariable('prometheus', 'old-prom-uid', 'Old Prometheus')],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getDatasourceVariable(result);
expect(variable).toBeDefined();
expect(variable?.spec.current?.value).toBe('new-prom-uid');
expect(variable?.spec.current?.text).toBe('New Prometheus');
});
});
describe.each([
{
variableType: 'AdhocVariable',
createVariable: (group: string, datasourceName: string) => ({
kind: 'AdhocVariable' as const,
group,
datasource: { name: datasourceName },
spec: { ...defaultAdhocVariableSpec(), name: 'Filters' },
}),
getVariable: getAdhocVariable,
},
{
variableType: 'GroupByVariable',
createVariable: (group: string, datasourceName: string) => ({
kind: 'GroupByVariable' as const,
group,
datasource: { name: datasourceName },
spec: { ...defaultGroupByVariableSpec(), name: 'groupby' },
}),
getVariable: getGroupByVariable,
},
])('$variableType', ({ createVariable, getVariable }) => {
it.each([
{ inputDs: 'old-prom-uid', expectedDs: 'new-prom-uid', desc: 'replaces hardcoded datasource' },
{ inputDs: '${ds}', expectedDs: '${ds}', desc: 'preserves variable reference' },
])('$desc', ({ inputDs, expectedDs }) => {
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createVariable('prometheus', inputDs)],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getVariable(result);
expect(variable).toBeDefined();
expect(variable?.datasource?.name).toBe(expectedDs);
});
});
describe('edge cases', () => {
it('handles mixed variable and hardcoded datasources', () => {
const dashboard: DashboardV2Spec = {
...baseDashboard,
elements: {
'panel-variable': createPanelWithQuery('prometheus', '${ds}'),
'panel-hardcoded': createPanelWithQuery('loki', 'old-loki-uid'),
},
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
expect(getPanelQueryDatasourceName(result, 'panel-variable')).toBe('${ds}');
expect(getPanelQueryDatasourceName(result, 'panel-hardcoded')).toBe('new-loki-uid');
});
});
});

View file

@ -1,168 +0,0 @@
import { AnnotationQueryKind, Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
export interface SelectedDatasource {
uid: string;
type: string;
name?: string;
}
/** Maps datasource type (e.g. "prometheus", "loki") to user-selected datasource from the import form */
export type DatasourceMappings = Record<string, SelectedDatasource>;
export function isVariableRef(dsName: string | undefined): boolean {
return dsName?.startsWith('$') ?? false;
}
export function replaceDatasourcesInDashboard(
dashboard: DashboardV2Spec,
mappings: DatasourceMappings
): DashboardV2Spec {
return {
...dashboard,
annotations: replaceAnnotationDatasources(dashboard.annotations, mappings),
variables: replaceVariableDatasources(dashboard.variables, mappings),
elements: replaceElementDatasources(dashboard.elements, mappings),
};
}
function replaceAnnotationDatasources(
annotations: DashboardV2Spec['annotations'],
mappings: DatasourceMappings
): DashboardV2Spec['annotations'] {
return annotations?.map((annotation: AnnotationQueryKind) => {
const dsType = annotation.spec.query?.group;
const currentDsName = annotation.spec.query?.datasource?.name;
const ds = dsType ? mappings[dsType] : undefined;
if (isVariableRef(currentDsName) || !dsType || !ds) {
return annotation;
}
return {
...annotation,
spec: {
...annotation.spec,
query: {
...annotation.spec.query,
datasource: { name: ds.uid },
},
},
};
});
}
function replaceVariableDatasources(
variables: DashboardV2Spec['variables'],
mappings: DatasourceMappings
): DashboardV2Spec['variables'] {
return variables?.map((variable) => {
if (variable.kind === 'QueryVariable') {
const dsType = variable.spec.query?.group;
const currentDsName = variable.spec.query?.datasource?.name;
const ds = dsType ? mappings[dsType] : undefined;
if (isVariableRef(currentDsName) || !dsType || !ds) {
return variable;
}
return {
...variable,
spec: {
...variable.spec,
query: {
...variable.spec.query,
datasource: { name: ds.uid },
},
options: [],
current: { text: '', value: '' },
refresh: 'onDashboardLoad' as const,
},
};
}
if (variable.kind === 'DatasourceVariable') {
const dsType = variable.spec.pluginId;
const ds = dsType ? mappings[dsType] : undefined;
if (!dsType || !ds) {
return variable;
}
return {
...variable,
spec: {
...variable.spec,
current: {
text: ds.name ?? ds.uid,
value: ds.uid,
},
},
};
}
if (variable.kind === 'AdhocVariable' || variable.kind === 'GroupByVariable') {
const dsType = variable.group;
const currentDsName = variable.datasource?.name;
const ds = dsType ? mappings[dsType] : undefined;
if (isVariableRef(currentDsName) || !dsType || !ds) {
return variable;
}
return {
...variable,
datasource: { name: ds.uid },
};
}
return variable;
});
}
function replaceElementDatasources(
elements: DashboardV2Spec['elements'],
mappings: DatasourceMappings
): DashboardV2Spec['elements'] {
return Object.fromEntries(
Object.entries(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) => {
if (query.kind !== 'PanelQuery') {
return query;
}
const queryType = query.spec.query?.group;
const currentDsName = query.spec.query?.datasource?.name;
const ds = queryType ? mappings[queryType] : undefined;
if (isVariableRef(currentDsName) || !queryType || !ds) {
return query;
}
return {
...query,
spec: {
...query.spec,
query: {
...query.spec.query,
datasource: { name: ds.uid },
},
},
};
});
panel.data = {
...panel.data,
spec: {
...panel.data.spec,
queries: newQueries,
},
};
}
return [key, { kind: element.kind, spec: panel }];
}
return [key, element];
})
);
}

View file

@ -2,6 +2,7 @@ import { t } from '@grafana/i18n';
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { safeStringifyValue } from 'app/core/utils/explore';
import { isRecord } from 'app/core/utils/isRecord';
import { GraphEdge, GraphNode, getPropsWithVariable } from 'app/features/variables/inspect/utils';
export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g;
@ -251,7 +252,3 @@ export const variableRegexExec = (variableString: string) => {
variableRegex.lastIndex = 0;
return variableRegex.exec(variableString);
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -6,6 +6,18 @@ import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types/dash
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
/**
* Represents the format/version of a dashboard for import/export operations.
* - Classic: Traditional Grafana dashboard JSON (v1 spec without k8s wrapper)
* - V1Resource: Kubernetes resource with v1 dashboard spec
* - V2Resource: Kubernetes resource with v2 dashboard spec (new layouts)
*/
export enum ExportFormat {
Classic = 'classic',
V1Resource = 'v1-resource',
V2Resource = 'v2-resource',
}
export type ListDeletedDashboardsOptions = Omit<ListOptions, 'labelSelector'>;
export interface DashboardAPI<G, T> {

View file

@ -1,63 +1,176 @@
import { config, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { Spec as DashboardV2Spec, Status } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Resource } from 'app/features/apiserver/types';
import { DashboardDataDTO } from 'app/types/dashboard';
import { getDashboardsApiVersion } from './utils';
import {
failedFromVersion,
getDashboardsApiVersion,
getFailedVersion,
isDashboardResource,
isDashboardV0Spec,
isDashboardV1Resource,
isDashboardV1Spec,
isDashboardV2Resource,
isDashboardV2Spec,
isV1ClassicDashboard,
isV1DashboardCommand,
isV2DashboardCommand,
} from './utils';
// Test data constants
const v1Spec: Dashboard = { title: 'Test', panels: [], schemaVersion: 1 };
const v2Spec = { elements: {}, layout: {} };
const v1Resource = { kind: 'DashboardWithAccessInfo', spec: v1Spec, metadata: { name: 'test' } };
const v2Resource = { kind: 'DashboardWithAccessInfo', spec: v2Spec, metadata: { name: 'test' } };
function createTestResource(
spec: Dashboard | DashboardV2Spec | DashboardDataDTO,
status?: Status
): Resource<Dashboard | DashboardV2Spec | DashboardDataDTO, Status> {
return {
apiVersion: 'v1',
kind: 'Dashboard',
metadata: { name: 'test', namespace: 'default', resourceVersion: '1', creationTimestamp: '2024-01-01T00:00:00Z' },
spec,
status,
};
}
describe('spec type guards handle unknown inputs safely', () => {
describe('isDashboardV2Spec', () => {
it.each([
['v2 spec', v2Spec, true],
['v1 spec', v1Spec, false],
['null', null, false],
['undefined', undefined, false],
['string', 'string', false],
['number', 123, false],
['array', [], false],
])('%s returns %s', (_name, input, expected) => {
expect(isDashboardV2Spec(input)).toBe(expected);
});
});
describe('isDashboardV1Spec', () => {
it.each([
['v1 spec', v1Spec, true],
['v2 spec', v2Spec, false],
['object without title', { panels: [] }, false],
['null', null, false],
])('%s returns %s', (_name, input, expected) => {
expect(isDashboardV1Spec(input)).toBe(expected);
});
});
it('isDashboardV0Spec distinguishes v0/v1 from v2', () => {
expect(isDashboardV0Spec({ title: 'Test' } as DashboardDataDTO)).toBe(true);
expect(isDashboardV0Spec({ elements: {} } as DashboardV2Spec)).toBe(false);
});
});
describe('resource type guards handle unknown inputs safely', () => {
describe('isDashboardResource', () => {
it.each([
['valid k8s resource', v1Resource, true],
['valid k8s resource with kind Dashboard', { kind: 'Dashboard', spec: v1Spec }, true],
['missing spec', { kind: 'DashboardWithAccessInfo' }, false],
['plain dashboard', v1Spec, false],
['null', null, false],
['undefined', undefined, false],
])('%s returns %s', (_name, input, expected) => {
expect(isDashboardResource(input)).toBe(expected);
});
});
it.each([
['isDashboardV2Resource', isDashboardV2Resource, v2Resource, v1Resource],
['isDashboardV1Resource', isDashboardV1Resource, v1Resource, v2Resource],
])('%s correctly identifies resources', (_name, guard, matching, nonMatching) => {
expect(guard(matching)).toBe(true);
expect(guard(nonMatching)).toBe(false);
expect(guard({ elements: {} })).toBe(false); // plain objects are not resources
});
});
describe('command type guards', () => {
it.each([
['v1 command', { dashboard: v1Spec as Dashboard }, true, false],
['v2 command', { dashboard: v2Spec as DashboardV2Spec }, false, true],
])('%s: isV1=%s, isV2=%s', (_name, cmd, isV1, isV2) => {
expect(isV1DashboardCommand(cmd)).toBe(isV1);
expect(isV2DashboardCommand(cmd)).toBe(isV2);
});
it('isV1ClassicDashboard distinguishes dashboard types', () => {
expect(isV1ClassicDashboard(v1Spec)).toBe(true);
expect(isV1ClassicDashboard(v2Spec as DashboardV2Spec)).toBe(false);
});
});
describe('conversion status helpers', () => {
it('getFailedVersion returns storedVersion only when failed', () => {
const failed = createTestResource(v1Spec, { conversion: { failed: true, storedVersion: 'v1alpha1' } });
const success = createTestResource(v1Spec, { conversion: { failed: false, storedVersion: 'v1alpha1' } });
const noStatus = createTestResource(v1Spec);
expect(getFailedVersion(failed)).toBe('v1alpha1');
expect(getFailedVersion(success)).toBeUndefined();
expect(getFailedVersion(noStatus)).toBeUndefined();
});
it.each([
[['v1'], 'v1alpha1', true],
[['v1', 'v2'], 'v2beta1', true],
[['v1'], 'v2alpha1', false],
])('failedFromVersion with prefixes %s and version %s returns %s', (prefixes, version, expected) => {
const item = createTestResource(v1Spec, { conversion: { failed: true, storedVersion: version } });
expect(failedFromVersion(item, prefixes)).toBe(expected);
});
it('failedFromVersion returns false when conversion did not fail', () => {
const item = createTestResource(v1Spec, { conversion: { failed: false, storedVersion: 'v1alpha1' } });
expect(failedFromVersion(item, ['v1'])).toBe(false);
});
});
describe('getDashboardsApiVersion', () => {
beforeEach(() => {
jest.resetModules();
locationService.push('/test');
});
it('should return v1 when dashboardScene is disabled and kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: false,
kubernetesDashboards: true,
};
expect(getDashboardsApiVersion()).toBe('v1');
it.each([
[{ dashboardScene: false, kubernetesDashboards: true }, undefined, 'v1'],
[{ dashboardScene: false, kubernetesDashboards: false }, undefined, 'legacy'],
[{ dashboardScene: true, kubernetesDashboards: true }, undefined, 'unified'],
[{ dashboardScene: true, kubernetesDashboards: false }, undefined, 'legacy'],
[{ dashboardScene: true, kubernetesDashboards: true }, 'v1', 'v1'],
[{ dashboardScene: true, kubernetesDashboards: true, dashboardNewLayouts: true }, undefined, 'v2'],
])('with toggles %j and responseFormat %s returns %s', (toggles, responseFormat, expected) => {
config.featureToggles = toggles;
expect(getDashboardsApiVersion(responseFormat as 'v1' | 'v2' | undefined)).toBe(expected);
});
it('should return legacy when dashboardScene is disabled and kubernetesDashboards is disabled', () => {
config.featureToggles = {
dashboardScene: false,
kubernetesDashboards: false,
};
expect(getDashboardsApiVersion()).toBe('legacy');
it('throws when requesting v2 without kubernetes dashboards', () => {
config.featureToggles = { dashboardScene: true, kubernetesDashboards: false };
expect(() => getDashboardsApiVersion('v2')).toThrow('v2 is not supported');
});
it('should return unified when dashboardScene is enabled and kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: true,
kubernetesDashboards: true,
};
expect(getDashboardsApiVersion()).toBe('unified');
it('throws when requesting v2 with legacy architecture', () => {
config.featureToggles = { dashboardScene: false, kubernetesDashboards: true };
expect(() => getDashboardsApiVersion('v2')).toThrow('v2 is not supported for legacy');
});
it('should return legacy when dashboardScene is enabled and kubernetesDashboards is disabled', () => {
config.featureToggles = {
dashboardScene: true,
kubernetesDashboards: false,
};
expect(getDashboardsApiVersion()).toBe('legacy');
});
describe('URL override scenes=false', () => {
beforeAll(() => locationService.push('/test?scenes=false'));
describe('forcing scenes through URL', () => {
beforeAll(() => {
locationService.push('/test?scenes=false');
});
it('should return legacy when kubernetesDashboards is disabled', () => {
config.featureToggles = {
dashboardScene: false,
kubernetesDashboards: false,
};
expect(getDashboardsApiVersion()).toBe('legacy');
});
it('should return v1 when kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: false,
kubernetesDashboards: true,
};
expect(getDashboardsApiVersion()).toBe('v1');
it.each([
[{ dashboardScene: false, kubernetesDashboards: false }, 'legacy'],
[{ dashboardScene: false, kubernetesDashboards: true }, 'v1'],
])('with toggles %j returns %s', (toggles, expected) => {
config.featureToggles = toggles;
expect(getDashboardsApiVersion()).toBe(expected);
});
});
});

View file

@ -2,9 +2,10 @@ import { config, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Status } from '@grafana/schema/src/schema/dashboard/v2';
import { isRecord } from 'app/core/utils/isRecord';
import { Resource } from 'app/features/apiserver/types';
import { isDashboardSceneEnabled } from 'app/features/dashboard-scene/utils/utils';
import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard';
import { DashboardDataDTO } from 'app/types/dashboard';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
@ -52,30 +53,29 @@ export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') {
return 'legacy';
}
// This function is used to determine if the dashboard is in v2 format or also v1 format
// This function is used to determine if the dashboard is a k8s resource (v1 or v2 format)
export function isDashboardResource(
obj?: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> | null
): obj is DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> {
if (!obj) {
return false;
}
// is v1 or v2 format?
const isK8sDashboard = 'kind' in obj && obj.kind === 'DashboardWithAccessInfo';
return isK8sDashboard;
value: unknown
): value is DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> {
return (
isRecord(value) && (value.kind === 'DashboardWithAccessInfo' || value.kind === 'Dashboard') && isRecord(value.spec)
);
}
export function isDashboardV2Spec(obj: Dashboard | DashboardDataDTO | DashboardV2Spec): obj is DashboardV2Spec {
return 'elements' in obj;
export function isDashboardV2Spec(obj: unknown): obj is DashboardV2Spec {
return isRecord(obj) && 'elements' in obj;
}
export function isDashboardV1Spec(obj: unknown): obj is Dashboard {
return isRecord(obj) && 'title' in obj && !isDashboardV2Spec(obj);
}
export function isDashboardV0Spec(obj: DashboardDataDTO | DashboardV2Spec): obj is DashboardDataDTO {
return !isDashboardV2Spec(obj); // not v2 spec means it's v1 spec
return !isDashboardV2Spec(obj);
}
export function isDashboardV2Resource(
obj: DashboardDTO | DashboardWithAccessInfo<DashboardDataDTO> | DashboardWithAccessInfo<DashboardV2Spec>
): obj is DashboardWithAccessInfo<DashboardV2Spec> {
return isDashboardResource(obj) && isDashboardV2Spec(obj.spec);
export function isDashboardV2Resource(value: unknown): value is DashboardWithAccessInfo<DashboardV2Spec> {
return isDashboardResource(value) && isDashboardV2Spec(value.spec);
}
export function isV1DashboardCommand(
@ -88,6 +88,10 @@ export function isV1ClassicDashboard(obj: Dashboard | DashboardV2Spec): obj is D
return !isDashboardV2Spec(obj);
}
export function isDashboardV1Resource(value: unknown): value is DashboardWithAccessInfo<DashboardDataDTO> {
return isDashboardResource(value) && !isDashboardV2Spec(value.spec);
}
export function isV2DashboardCommand(
cmd: SaveDashboardCommand<Dashboard | DashboardV2Spec>
): cmd is SaveDashboardCommand<DashboardV2Spec> {

View file

@ -1,7 +1,7 @@
import { screen, waitFor } from '@testing-library/react';
import { render } from 'test/test-utils';
import { DataSourceInput, DashboardInput, InputType } from 'app/features/manage-dashboards/state/reducers';
import { DataSourceInput, DashboardInput, InputType } from 'app/features/manage-dashboards/types';
import { CommunityDashboardMappingForm } from './CommunityDashboardMappingForm';
import { CONTENT_KINDS, ContentKind, EVENT_LOCATIONS, EventLocation } from './interactions';

View file

@ -5,7 +5,7 @@ import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { Stack, Text, Button, Alert, Field, Input, Box } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/types';
import { ContentKind, DashboardLibraryInteractions, EventLocation, SOURCE_ENTRY_POINTS } from './interactions';
import { InputMapping, mapConstantInputs, mapUserSelectedDatasources } from './utils/autoMapDatasources';

View file

@ -6,8 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { Modal, TabsBar, Tab, TabContent, useStyles2, Text } from '@grafana/ui';
import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { DashboardInput, DataSourceInput, DashboardJson } from 'app/features/manage-dashboards/types';
import { CommunityDashboardMappingForm } from './CommunityDashboardMappingForm';
import { CommunityDashboardSection } from './CommunityDashboardSection';

View file

@ -1,6 +1,6 @@
import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime';
import { Input } from 'app/features/dashboard/components/DashExportModal/DashboardExporter';
import { DashboardInput, DataSourceInput, InputType } from 'app/features/manage-dashboards/state/reducers';
import { DashboardInput, DataSourceInput, InputType } from 'app/features/manage-dashboards/types';
import {
isDataSourceInput,

View file

@ -1,6 +1,6 @@
import { getDataSourceSrv } from '@grafana/runtime';
import { Input } from 'app/features/dashboard/components/DashExportModal/DashboardExporter';
import { DashboardInput, DataSourceInput, InputType } from 'app/features/manage-dashboards/state/reducers';
import { DashboardInput, DataSourceInput, InputType } from 'app/features/manage-dashboards/types';
export interface InputMapping {
name: string;

View file

@ -1,6 +1,5 @@
import { locationService } from '@grafana/runtime';
import { InputType, DataSourceInput, DashboardInput } from 'app/features/manage-dashboards/state/reducers';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { InputType, DataSourceInput, DashboardInput, DashboardJson } from 'app/features/manage-dashboards/types';
import { DASHBOARD_LIBRARY_ROUTES } from '../../types';
import { fetchCommunityDashboard } from '../api/dashboardLibraryApi';

View file

@ -3,8 +3,7 @@ import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { notifyApp } from 'app/core/reducers/appNotification';
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { DataSourceInput, DashboardJson } from 'app/features/manage-dashboards/types';
import { dispatch } from 'app/types/store';
import { DASHBOARD_LIBRARY_ROUTES } from '../../types';

View file

@ -0,0 +1,60 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps';
import DashboardImportPage from './DashboardImportPage';
jest.mock('./import/components/DashboardImportK8s', () => ({
DashboardImportK8s: jest.fn(() => <div data-testid="import-k8s" />),
}));
jest.mock('./import/legacy/DashboardImportLegacy', () => ({
DashboardImportLegacy: jest.fn(() => <div data-testid="import-legacy" />),
}));
const renderPage = () => {
const props = getRouteComponentProps({
route: {
routeName: 'import-dashboard-test',
path: '/dashboards/import',
component: () => null,
},
});
return render(<DashboardImportPage {...props} />);
};
describe('DashboardImportPage', () => {
beforeEach(() => {
config.featureToggles = {};
jest.clearAllMocks();
});
describe('kubernetesDashboards enabled', () => {
beforeEach(() => {
config.featureToggles.kubernetesDashboards = true;
});
it('renders k8s import page', () => {
renderPage();
expect(screen.getByTestId('import-k8s')).toBeInTheDocument();
expect(screen.queryByTestId('import-legacy')).not.toBeInTheDocument();
});
});
describe('kubernetesDashboards disabled', () => {
beforeEach(() => {
config.featureToggles.kubernetesDashboards = false;
});
it('renders legacy import page', () => {
renderPage();
expect(screen.getByTestId('import-legacy')).toBeInTheDocument();
expect(screen.queryByTestId('import-k8s')).not.toBeInTheDocument();
});
});
});

View file

@ -1,327 +1,27 @@
import { css } from '@emotion/css';
import { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { AppEvents, GrafanaTheme2, LoadingState, NavModelItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import {
Button,
Field,
Input,
Spinner,
stylesFactory,
TextArea,
Themeable2,
FileDropzone,
withTheme2,
DropzoneFile,
FileDropzoneDefaultChildren,
LinkButton,
TextLink,
Label,
Stack,
} from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { Form } from 'app/core/components/Form/Form';
import { Page } from 'app/core/components/Page/Page';
import { config } from '@grafana/runtime';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { dispatch } from 'app/store/store';
import { StoreState } from 'app/types/store';
import { cleanUpAction } from '../../core/actions/cleanUp';
import { ImportDashboardOverviewV2 } from '../dashboard-scene/v2schema/ImportDashboardOverviewV2';
import { DashboardImportK8s } from './import/components/DashboardImportK8s';
import { DashboardImportLegacy } from './import/legacy/DashboardImportLegacy';
import { ImportDashboardOverview } from './components/ImportDashboardOverview';
import { fetchGcomDashboard, importDashboardJson, importDashboardV2Json } from './state/actions';
import { initialImportDashboardState } from './state/reducers';
import { validateDashboardJson, validateGcomDashboard } from './utils/validation';
type RouteParams = {};
type QueryParams = { gcomDashboardId?: string };
type DashboardImportPageRouteSearchParams = {
gcomDashboardId?: string;
};
type Props = GrafanaRouteComponentProps<RouteParams, QueryParams>;
type OwnProps = Themeable2 & GrafanaRouteComponentProps<{}, DashboardImportPageRouteSearchParams>;
/**
* Dashboard Import Page
*
* Routes to different implementations based on the kubernetesDashboards feature toggle:
* - DashboardImportK8s: Non-Redux implementation using local state and direct k8s API calls
* - legacy/DashboardImportLegacy: Redux-based implementation using /api/dashboards/import endpoint
*
* When kubernetesDashboards feature is removed, delete the legacy/ folder entirely.
*/
export default function DashboardImportPage(props: Props) {
if (config.featureToggles.kubernetesDashboards) {
return <DashboardImportK8s {...props} />;
}
const IMPORT_STARTED_EVENT_NAME = 'dashboard_import_loaded';
const JSON_PLACEHOLDER = `{
"title": "Example - Repeating Dictionary variables",
"uid": "_0HnEoN4z",
"panels": [...]
...
return <DashboardImportLegacy {...props} />;
}
`;
const mapStateToProps = (state: StoreState) => ({
loadingState: state.importDashboard.state,
dashboard: state.importDashboard.dashboard,
});
const mapDispatchToProps = {
fetchGcomDashboard,
importDashboardJson,
cleanUpAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
class UnthemedDashboardImport extends PureComponent<Props> {
constructor(props: Props) {
super(props);
const { gcomDashboardId } = this.props.queryParams;
if (gcomDashboardId) {
this.getGcomDashboard({ gcomDashboard: gcomDashboardId });
return;
}
}
componentWillUnmount() {
this.props.cleanUpAction({ cleanupAction: (state) => (state.importDashboard = initialImportDashboardState) });
}
// Do not display upload file list
fileListRenderer = (file: DropzoneFile, removeFile: (file: DropzoneFile) => void) => null;
onFileUpload = (result: string | ArrayBuffer | null) => {
reportInteraction(IMPORT_STARTED_EVENT_NAME, {
import_source: 'json_uploaded',
});
try {
const json = JSON.parse(String(result));
if (json.spec?.elements) {
return dispatch(importDashboardV2Json(json.spec));
} else if (json.elements) {
return dispatch(importDashboardV2Json(json));
}
// check if it's a v1 resource format
if (json.spec) {
return this.props.importDashboardJson(json.spec);
}
this.props.importDashboardJson(json);
} catch (error) {
if (error instanceof Error) {
appEvents.emit(AppEvents.alertError, ['Import failed', 'JSON -> JS Serialization failed: ' + error.message]);
}
return;
}
};
getDashboardFromJson = (formData: { dashboardJson: string }) => {
reportInteraction(IMPORT_STARTED_EVENT_NAME, {
import_source: 'json_pasted',
});
const dashboard = JSON.parse(formData.dashboardJson);
if ((dashboard.spec?.elements || dashboard.elements) && !config.featureToggles.dashboardNewLayouts) {
return appEvents.emit(AppEvents.alertError, [
'Import failed',
'Dashboard using new layout cannot be imported because the feature is not enabled',
]);
}
// check if it's a v2 resource format
if (dashboard.spec?.elements) {
return dispatch(importDashboardV2Json(dashboard.spec));
}
// check if it's just a v2 spec
if (dashboard.elements) {
return dispatch(importDashboardV2Json(dashboard));
}
// check if it's a v1 resource format
if (dashboard.spec) {
return this.props.importDashboardJson(dashboard.spec);
}
this.props.importDashboardJson(dashboard);
};
getGcomDashboard = (formData: { gcomDashboard: string }) => {
reportInteraction(IMPORT_STARTED_EVENT_NAME, {
import_source: 'gcom',
});
let dashboardId;
const match = /(^\d+$)|dashboards\/(\d+)/.exec(formData.gcomDashboard);
if (match && match[1]) {
dashboardId = match[1];
} else if (match && match[2]) {
dashboardId = match[2];
}
if (dashboardId) {
this.props.fetchGcomDashboard(dashboardId);
}
};
renderImportForm() {
const styles = importStyles(this.props.theme);
const GcomDashboardsLink = () => (
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
<TextLink variant="bodySmall" href="https://grafana.com/grafana/dashboards/" external>
grafana.com/dashboards
</TextLink>
);
return (
<>
<div className={styles.option}>
<FileDropzone
options={{ multiple: false, accept: ['.json', '.txt'] }}
readAs="readAsText"
fileListRenderer={this.fileListRenderer}
onLoad={this.onFileUpload}
>
<FileDropzoneDefaultChildren
primaryText={t('dashboard-import.file-dropzone.primary-text', 'Upload dashboard JSON file')}
secondaryText={t(
'dashboard-import.file-dropzone.secondary-text',
'Drag and drop here or click to browse'
)}
/>
</FileDropzone>
</div>
<div className={styles.option}>
<Form onSubmit={this.getGcomDashboard} defaultValues={{ gcomDashboard: '' }}>
{({ register, errors }) => (
<Field
label={
<Label className={styles.labelWithLink} htmlFor="url-input">
<span>
<Trans i18nKey="dashboard-import.gcom-field.label">
Find and import dashboards for common applications at <GcomDashboardsLink />
</Trans>
</span>
</Label>
}
invalid={!!errors.gcomDashboard}
error={errors.gcomDashboard && errors.gcomDashboard.message}
>
<Input
id="url-input"
placeholder={t('dashboard-import.gcom-field.placeholder', 'Grafana.com dashboard URL or ID')}
type="text"
{...register('gcomDashboard', {
required: t(
'dashboard-import.gcom-field.validation-required',
'A Grafana dashboard URL or ID is required'
),
validate: validateGcomDashboard,
})}
addonAfter={
<Button type="submit">
<Trans i18nKey="dashboard-import.gcom-field.load-button">Load</Trans>
</Button>
}
/>
</Field>
)}
</Form>
</div>
<div className={styles.option}>
<Form onSubmit={this.getDashboardFromJson} defaultValues={{ dashboardJson: '' }}>
{({ register, errors }) => (
<>
<Field
label={t('dashboard-import.json-field.label', 'Import via dashboard JSON model')}
invalid={!!errors.dashboardJson}
error={errors.dashboardJson && errors.dashboardJson.message}
>
<TextArea
{...register('dashboardJson', {
required: t('dashboard-import.json-field.validation-required', 'Need a dashboard JSON model'),
validate: validateDashboardJson,
})}
data-testid={selectors.components.DashboardImportPage.textarea}
id="dashboard-json-textarea"
rows={10}
placeholder={JSON_PLACEHOLDER}
/>
</Field>
<Stack>
<Button type="submit" data-testid={selectors.components.DashboardImportPage.submit}>
<Trans i18nKey="dashboard-import.form-actions.load">Load</Trans>
</Button>
<LinkButton variant="secondary" href={`${config.appSubUrl}/dashboards`}>
<Trans i18nKey="dashboard-import.form-actions.cancel">Cancel</Trans>
</LinkButton>
</Stack>
</>
)}
</Form>
</div>
</>
);
}
pageNav: NavModelItem = {
text: t('manage-dashboards.unthemed-dashboard-import.text.import-dashboard', 'Import dashboard'),
subTitle: t(
'manage-dashboards.unthemed-dashboard-import.subTitle.import-dashboard-from-file-or-grafanacom',
'Import dashboard from file or Grafana.com'
),
};
getDashboardOverview() {
const { loadingState, dashboard } = this.props;
if (loadingState === LoadingState.Done) {
if (dashboard.elements || dashboard.spec?.elements) {
return <ImportDashboardOverviewV2 />;
}
return <ImportDashboardOverview />;
}
return null;
}
render() {
const { loadingState } = this.props;
return (
<Page navId="dashboards/browse" pageNav={this.pageNav}>
<Page.Contents>
{loadingState === LoadingState.Loading && (
<Stack direction={'column'} justifyContent="center">
<Stack justifyContent="center">
<Spinner size="xxl" />
</Stack>
</Stack>
)}
{[LoadingState.Error, LoadingState.NotStarted].includes(loadingState) && this.renderImportForm()}
{this.getDashboardOverview()}
</Page.Contents>
</Page>
);
}
}
const DashboardImportUnConnected = withTheme2(UnthemedDashboardImport);
const DashboardImport = connector(DashboardImportUnConnected);
DashboardImport.displayName = 'DashboardImport';
export default DashboardImport;
const importStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
option: css({
marginBottom: theme.spacing(4),
maxWidth: '600px',
}),
labelWithLink: css({
maxWidth: '100%',
}),
linkWithinLabel: css({
fontSize: 'inherit',
}),
};
});

View file

@ -1,204 +0,0 @@
import { useEffect, useState } from 'react';
import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { ExpressionDatasourceRef } from '@grafana/runtime/internal';
import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input, Legend } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import {
DashboardInput,
DashboardInputs,
DataSourceInput,
ImportDashboardDTO,
LibraryPanelInputState,
} from '../state/reducers';
import { validateTitle, validateUid } from '../utils/validation';
import { ImportDashboardLibraryPanelsList } from './ImportDashboardLibraryPanelsList';
interface Props extends Pick<UseFormReturn<ImportDashboardDTO>, 'register' | 'control' | 'getValues' | 'watch'> {
uidReset: boolean;
inputs: DashboardInputs;
errors: FieldErrors<ImportDashboardDTO>;
onCancel: () => void;
onUidReset: () => void;
onSubmit: FormsOnSubmit<ImportDashboardDTO>;
}
export const ImportDashboardForm = ({
register,
errors,
control,
getValues,
uidReset,
inputs,
onUidReset,
onCancel,
onSubmit,
watch,
}: Props) => {
const [isSubmitted, setSubmitted] = useState(false);
const watchDataSources = watch('dataSources');
const watchFolder = watch('folder');
/*
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.title || errors.uid)) {
onSubmit(getValues());
}
}, [errors, getValues, isSubmitted, onSubmit]);
const newLibraryPanels = inputs?.libraryPanels?.filter((i) => i.state === LibraryPanelInputState.New) ?? [];
const existingLibraryPanels = inputs?.libraryPanels?.filter((i) => i.state === LibraryPanelInputState.Exists) ?? [];
return (
<>
<Legend>
<Trans i18nKey="manage-dashboards.import-dashboard-form.options">Options</Trans>
</Legend>
<Field
label={t('manage-dashboards.import-dashboard-form.label-name', 'Name')}
invalid={!!errors.title}
error={errors.title && errors.title.message}
>
<Input
{...register('title', {
required: 'Name is required',
validate: async (v: string) => await validateTitle(v, getValues().folder.uid),
})}
type="text"
data-testid={selectors.components.ImportDashboardForm.name}
/>
</Field>
<Field label={t('manage-dashboards.import-dashboard-form.label-folder', 'Folder')}>
<Controller
render={({ field: { ref, value, onChange, ...field } }) => (
<FolderPicker {...field} onChange={(uid, title) => onChange({ uid, title })} value={value.uid} />
)}
name="folder"
control={control}
/>
</Field>
<Field
label={t('manage-dashboards.import-dashboard-form.label-unique-identifier-uid', 'Unique identifier (UID)')}
description={t(
'manage-dashboards.import-dashboard-form.description-unique-identifier-uid',
'The unique identifier (UID) of a dashboard can be used for uniquely identify a dashboard between multiple Grafana installs. The UID allows having consistent URLs for accessing dashboards so changing the title of a dashboard will not break any bookmarked links to that dashboard.'
)}
invalid={!!errors.uid}
error={errors.uid && errors.uid.message}
>
<>
{!uidReset ? (
<Input
disabled
{...register('uid', { validate: async (v: string) => await validateUid(v) })}
addonAfter={
!uidReset && (
<Button onClick={onUidReset}>
<Trans i18nKey="manage-dashboards.import-dashboard-form.change-uid">Change uid</Trans>
</Button>
)
}
/>
) : (
<Input {...register('uid', { required: true, validate: async (v: string) => await validateUid(v) })} />
)}
</>
</Field>
{inputs.dataSources &&
inputs.dataSources.map((input: DataSourceInput, index: number) => {
if (input.pluginId === ExpressionDatasourceRef.type) {
return null;
}
const dataSourceOption = `dataSources.${index}` as const;
const current = watchDataSources ?? [];
return (
<Field
label={input.name}
description={input.description}
key={dataSourceOption}
invalid={errors.dataSources && !!errors.dataSources[index]}
error={errors.dataSources && errors.dataSources[index] && 'A data source is required'}
>
<Controller
name={dataSourceOption}
render={({ field: { ref, ...field } }) => (
<DataSourcePicker
{...field}
noDefault={true}
placeholder={input.info}
pluginId={input.pluginId}
current={current[index]?.uid}
/>
)}
control={control}
rules={{ required: true }}
/>
</Field>
);
})}
{inputs.constants &&
inputs.constants.map((input: DashboardInput, index) => {
const constantIndex = `constants.${index}` as const;
return (
<Field
label={input.label}
error={errors.constants && errors.constants[index] && `${input.label} needs a value`}
invalid={errors.constants && !!errors.constants[index]}
key={constantIndex}
>
<Input {...register(constantIndex, { required: true })} defaultValue={input.value} />
</Field>
);
})}
<ImportDashboardLibraryPanelsList
inputs={newLibraryPanels}
label={t('manage-dashboards.import-dashboard-form.label-new-library-panels', 'New library panels')}
description={t(
'manage-dashboards.import-dashboard-form.description-library-panels-imported',
'List of new library panels that will get imported.'
)}
folderName={watchFolder.title}
/>
<ImportDashboardLibraryPanelsList
inputs={existingLibraryPanels}
label={t('manage-dashboards.import-dashboard-form.label-existing-library-panels', 'Existing library panels')}
description={t(
'manage-dashbaords.import-dashboard-form.description-existing-library-panels',
'List of existing library panels. These panels are not affected by the import.'
)}
folderName={watchFolder.title}
/>
<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}>
<Trans i18nKey="manage-dashboards.import-dashboard-form.cancel">Cancel</Trans>
</Button>
</Stack>
</>
);
};
function getButtonVariant(errors: FormFieldErrors<ImportDashboardDTO>) {
return errors && (errors.title || errors.uid) ? 'destructive' : 'primary';
}
function getButtonText(errors: FormFieldErrors<ImportDashboardDTO>) {
return errors && (errors.title || errors.uid) ? 'Import (Overwrite)' : 'Import';
}

View file

@ -1,62 +0,0 @@
import { css } from '@emotion/css';
import { ReactElement } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { LibraryPanel } from '@grafana/schema';
import { Field, useStyles2 } from '@grafana/ui';
import { LibraryPanelCard } from '../../library-panels/components/LibraryPanelCard/LibraryPanelCard';
import { LibraryPanelInput, LibraryPanelInputState } from '../state/reducers';
interface ImportDashboardLibraryPanelsListProps {
inputs: LibraryPanelInput[];
label: string;
description: string;
folderName?: string;
}
export function ImportDashboardLibraryPanelsList({
inputs,
label,
description,
folderName,
}: ImportDashboardLibraryPanelsListProps): ReactElement | null {
const styles = useStyles2(getStyles);
if (!Boolean(inputs?.length)) {
return null;
}
return (
<div className={styles.spacer}>
<Field label={label} description={description}>
<>
{inputs.map((input, index) => {
const libraryPanelIndex = `elements[${index}]`;
const libraryPanel =
input.state === LibraryPanelInputState.New
? { ...input.model, meta: { ...input.model.meta, folderName: folderName ?? 'Dashboards' } }
: { ...input.model };
return (
<div className={styles.item} key={libraryPanelIndex}>
<LibraryPanelCard libraryPanel={libraryPanel as LibraryPanel} onClick={() => undefined} />
</div>
);
})}
</>
</Field>
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
spacer: css({
marginBottom: theme.spacing(2),
}),
item: css({
marginBottom: theme.spacing(1),
}),
};
}

View file

@ -1,128 +0,0 @@
import { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { dateTimeFormat } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Box, Legend, TextLink } from '@grafana/ui';
import { Form } from 'app/core/components/Form/Form';
import { StoreState } from 'app/types/store';
import { clearLoadedDashboard, importDashboard } from '../state/actions';
import { DashboardSource, ImportDashboardDTO } from '../state/reducers';
import { ImportDashboardForm } from './ImportDashboardForm';
const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported';
const mapStateToProps = (state: StoreState) => {
const searchObj = locationService.getSearchObject();
return {
dashboard: state.importDashboard.dashboard,
meta: state.importDashboard.meta,
source: state.importDashboard.source,
inputs: state.importDashboard.inputs,
folder: searchObj.folderUid ? { uid: String(searchObj.folderUid) } : { uid: '' },
};
};
const mapDispatchToProps = {
clearLoadedDashboard,
importDashboard,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = ConnectedProps<typeof connector>;
interface State {
uidReset: boolean;
}
class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
state: State = {
uidReset: false,
};
onSubmit = (form: ImportDashboardDTO) => {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
this.props.importDashboard(form);
};
onCancel = () => {
this.props.clearLoadedDashboard();
};
onUidReset = () => {
this.setState({ uidReset: true });
};
render() {
const { dashboard, inputs, meta, source, folder } = this.props;
const { uidReset } = this.state;
return (
<>
{source === DashboardSource.Gcom && (
<Box marginBottom={3}>
<div>
<Legend>
<Trans i18nKey="manage-dashboards.import-dashboard-overview-un-connected.importing-from">
Importing dashboard from{' '}
<TextLink href={`https://grafana.com/dashboards/${dashboard.gnetId}`}>Grafana.com</TextLink>
</Trans>
</Legend>
</div>
<table className="filter-table form-inline">
<tbody>
<tr>
<td>
<Trans i18nKey="manage-dashboards.import-dashboard-overview-un-connected.published-by">
Published by
</Trans>
</td>
<td>{meta.orgName}</td>
</tr>
<tr>
<td>
<Trans i18nKey="manage-dashboards.import-dashboard-overview-un-connected.updated-on">
Updated on
</Trans>
</td>
<td>{dateTimeFormat(meta.updatedAt)}</td>
</tr>
</tbody>
</table>
</Box>
)}
<Form
onSubmit={this.onSubmit}
defaultValues={{ ...dashboard, constants: [], dataSources: [], elements: [], folder: folder }}
validateOnMount
validateFieldsOnMount={['title', 'uid']}
validateOn="onChange"
>
{({ register, errors, control, watch, getValues }) => (
<ImportDashboardForm
register={register}
errors={errors}
control={control}
getValues={getValues}
uidReset={uidReset}
inputs={inputs}
onCancel={this.onCancel}
onUidReset={this.onUidReset}
onSubmit={this.onSubmit}
watch={watch}
/>
)}
</Form>
</>
);
}
}
export const ImportDashboardOverview = connector(ImportDashboardOverviewUnConnected);
ImportDashboardOverview.displayName = 'ImportDashboardOverview';

View file

@ -0,0 +1,193 @@
import { useState, useEffect } from 'react';
import { AppEvents, LoadingState } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getBackendSrv, isFetchError, reportInteraction } from '@grafana/runtime';
import { Spinner, Stack } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { ExportFormat } from 'app/features/dashboard/api/types';
import { isDashboardV1Resource, isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { DashboardInputs, DashboardSource } from '../../types';
import { detectExportFormat, extractV1Inputs, extractV2Inputs } from '../utils/inputs';
import { ImportOverview } from './ImportOverview';
import { ImportSourceForm } from './ImportSourceForm';
const IMPORT_STARTED_EVENT_NAME = 'dashboard_import_loaded';
type RouteParams = {};
type QueryParams = { gcomDashboardId?: string };
type Props = GrafanaRouteComponentProps<RouteParams, QueryParams>;
type ImportState = {
status: LoadingState;
dashboard: unknown;
inputs: DashboardInputs;
meta: { updatedAt: string; orgName: string };
source: DashboardSource;
format: ExportFormat;
};
const initialState: ImportState = {
status: LoadingState.NotStarted,
dashboard: {},
inputs: { dataSources: [], constants: [], libraryPanels: [] },
meta: { updatedAt: '', orgName: '' },
source: DashboardSource.Json,
format: ExportFormat.Classic,
};
export function DashboardImportK8s({ queryParams }: Props) {
const [state, setState] = useState<ImportState>(initialState);
// Handle gcom dashboard ID from query params on mount
useEffect(() => {
const { gcomDashboardId } = queryParams;
if (gcomDashboardId) {
fetchGcomDashboard(gcomDashboardId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function fetchGcomDashboard(id: string) {
reportInteraction(IMPORT_STARTED_EVENT_NAME, { import_source: 'gcom' });
setState((prev) => ({ ...prev, status: LoadingState.Loading }));
try {
const response = await getBackendSrv().get(`/api/gnet/dashboards/${id}`);
const dashboard = response.json;
const format = detectExportFormat(dashboard);
const inputs = format === ExportFormat.V2Resource ? extractV2Inputs(dashboard) : await extractV1Inputs(dashboard);
setState({
status: LoadingState.Done,
dashboard,
inputs,
meta: { updatedAt: response.updatedAt, orgName: response.orgName },
source: DashboardSource.Gcom,
format,
});
} catch (error) {
setState((prev) => ({ ...prev, status: LoadingState.Error }));
if (isFetchError(error)) {
appEvents.emit(AppEvents.alertError, ['Failed to load dashboard', error.data?.message || 'Unknown error']);
}
}
}
async function handleFileUpload(result: string | ArrayBuffer | null) {
reportInteraction(IMPORT_STARTED_EVENT_NAME, { import_source: 'json_uploaded' });
try {
const json = JSON.parse(String(result));
await processDashboardJson(json);
} catch (error) {
if (error instanceof Error) {
appEvents.emit(AppEvents.alertError, ['Import failed', 'JSON -> JS Serialization failed: ' + error.message]);
}
}
}
async function handleJsonPaste(formData: { dashboardJson: string }) {
reportInteraction(IMPORT_STARTED_EVENT_NAME, { import_source: 'json_pasted' });
const json = JSON.parse(formData.dashboardJson);
if ((json.spec?.elements || json.elements) && !config.featureToggles.dashboardNewLayouts) {
appEvents.emit(AppEvents.alertError, [
'Import failed',
'Dashboard using new layout cannot be imported because the feature is not enabled',
]);
return;
}
await processDashboardJson(json);
}
async function processDashboardJson(json: unknown) {
setState((prev) => ({ ...prev, status: LoadingState.Loading }));
try {
const format = detectExportFormat(json);
// Unwrap k8s resource to get the spec, or use as-is for classic dashboards
const dashboard = isDashboardV2Resource(json) || isDashboardV1Resource(json) ? json.spec : json;
const inputs = format === ExportFormat.V2Resource ? extractV2Inputs(dashboard) : await extractV1Inputs(dashboard);
setState({
status: LoadingState.Done,
dashboard,
inputs,
meta: { updatedAt: '', orgName: '' },
source: DashboardSource.Json,
format,
});
} catch (error) {
setState((prev) => ({ ...prev, status: LoadingState.Error }));
const message = error instanceof Error ? error.message : 'Unknown error';
appEvents.emit(AppEvents.alertError, ['Failed to process dashboard', message]);
}
}
function handleGcomSubmit(formData: { gcomDashboard: string }) {
let dashboardId;
const match = /(^\d+$)|dashboards\/(\d+)/.exec(formData.gcomDashboard);
if (match && match[1]) {
dashboardId = match[1];
} else if (match && match[2]) {
dashboardId = match[2];
}
if (dashboardId) {
fetchGcomDashboard(dashboardId);
}
}
function handleCancel() {
setState(initialState);
}
const pageNav = {
text: t('manage-dashboards.unthemed-dashboard-import.text.import-dashboard', 'Import dashboard'),
subTitle: t(
'manage-dashboards.unthemed-dashboard-import.subTitle.import-dashboard-from-file-or-grafanacom',
'Import dashboard from file or Grafana.com'
),
};
return (
<Page navId="dashboards/browse" pageNav={pageNav}>
<Page.Contents>
{state.status === LoadingState.Loading && (
<Stack direction="column" justifyContent="center">
<Stack justifyContent="center">
<Spinner size="xxl" />
</Stack>
</Stack>
)}
{(state.status === LoadingState.NotStarted || state.status === LoadingState.Error) && (
<ImportSourceForm
onFileUpload={handleFileUpload}
onGcomSubmit={handleGcomSubmit}
onJsonSubmit={handleJsonPaste}
/>
)}
{state.status === LoadingState.Done && (
<ImportOverview
dashboard={state.dashboard}
inputs={state.inputs}
meta={state.meta}
source={state.source}
onCancel={handleCancel}
/>
)}
</Page.Contents>
</Page>
);
}

View file

@ -0,0 +1,49 @@
import { dateTimeFormat, textUtil } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Box, Legend, TextLink } from '@grafana/ui';
type Props = {
gnetId: string | number | undefined;
orgName: string;
updatedAt: string;
};
function buildGcomDashboardUrl(gnetId: string | number | undefined): string {
const url = new URL('https://grafana.com/dashboards');
if (gnetId !== undefined) {
url.pathname = `/dashboards/${String(gnetId)}`;
}
return textUtil.sanitizeUrl(url.toString());
}
export function GcomDashboardInfo({ gnetId, orgName, updatedAt }: Props) {
return (
<Box marginBottom={3}>
<div>
<Legend>
<Trans i18nKey="manage-dashboards.import-dashboard-overview-un-connected.importing-from">
Importing dashboard from <TextLink href={buildGcomDashboardUrl(gnetId)}>Grafana.com</TextLink>
</Trans>
</Legend>
</div>
<table className="filter-table form-inline">
<tbody>
<tr>
<td>
<Trans i18nKey="manage-dashboards.import-dashboard-overview-un-connected.published-by">
Published by
</Trans>
</td>
<td>{orgName}</td>
</tr>
<tr>
<td>
<Trans i18nKey="manage-dashboards.import-dashboard-overview-un-connected.updated-on">Updated on</Trans>
</td>
<td>{dateTimeFormat(updatedAt)}</td>
</tr>
</tbody>
</table>
</Box>
);
}

View file

@ -1,39 +1,27 @@
import { useEffect, useState } from 'react';
import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form';
import { Controller, FieldErrors, FieldPath, UseFormReturn } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { ExpressionDatasourceRef } from '@grafana/runtime/internal';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
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'
> {
import { DashboardInputs, DatasourceSelection, DataSourceInput, ImportFormDataV2 } from '../../types';
import { validateTitle } from '../utils/validation';
interface Props extends Pick<UseFormReturn<ImportFormDataV2>, 'register' | 'control' | 'getValues' | 'watch'> {
inputs: DashboardInputs;
errors: FieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>;
errors: FieldErrors<ImportFormDataV2>;
onCancel: () => void;
onSubmit: FormsOnSubmit<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>;
onSubmit: FormsOnSubmit<ImportFormDataV2>;
}
export const ImportDashboardFormV2 = ({
register,
errors,
control,
inputs,
getValues,
onCancel,
onSubmit,
watch,
}: Props) => {
export const ImportDashboardFormV2 = ({ register, errors, control, inputs, getValues, onCancel, onSubmit }: Props) => {
const [isSubmitted, setSubmitted] = useState(false);
const [selectedDataSources, setSelectedDataSources] = useState<Record<string, { uid: string; type: string }>>({});
const [selectedDataSources, setSelectedDataSources] = useState<Record<string, DatasourceSelection>>({});
/*
This useEffect is needed for overwriting a dashboard. It
submits the form even if there's validation errors on title or uid.
@ -60,9 +48,9 @@ export const ImportDashboardFormV2 = ({
noMargin
>
<Input
{...(register as any)('dashboard.title', {
{...register('dashboard.title', {
required: 'Name is required',
validate: async (v: string) => await validateTitle(v, getValues().folderUid ?? ''),
validate: async (v) => await validateTitle(String(v ?? ''), getValues().folderUid ?? ''),
})}
type="text"
data-testid={selectors.components.ImportDashboardForm.name}
@ -70,7 +58,7 @@ export const ImportDashboardFormV2 = ({
</Field>
<Field label={t('dashboard-scene.import-dashboard-form-v2.label-folder', 'Folder')} noMargin>
<Controller<any>
<Controller
render={({ field: { ref, value, onChange, ...field } }) => (
<FolderPicker
{...field}
@ -91,7 +79,7 @@ export const ImportDashboardFormV2 = ({
return null;
}
const dataSourceOption = `datasource-${input.pluginId}` as const;
const dataSourceOption = `datasource-${input.pluginId}`;
return (
<Field
@ -102,7 +90,7 @@ export const ImportDashboardFormV2 = ({
error={errors[dataSourceOption] ? 'Please select a data source' : undefined}
noMargin
>
<Controller<any>
<Controller<ImportFormDataV2, FieldPath<ImportFormDataV2>>
name={dataSourceOption}
render={({ field: { ref, ...field } }) => (
<DataSourcePicker
@ -113,7 +101,6 @@ export const ImportDashboardFormV2 = ({
current={selectedDataSources[input.pluginId]}
onChange={(ds) => {
field.onChange(ds);
// Update our selected datasources map
setSelectedDataSources((prev) => ({
...prev,
[input.pluginId]: {
@ -151,14 +138,10 @@ export const ImportDashboardFormV2 = ({
);
};
function getButtonVariant(
errors: FormFieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
) {
function getButtonVariant(errors: FormFieldErrors<ImportFormDataV2>) {
return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'destructive' : 'primary';
}
function getButtonText(
errors: FormFieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
) {
function getButtonText(errors: FormFieldErrors<ImportFormDataV2>) {
return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'Import (Overwrite)' : 'Import';
}

View file

@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { ExpressionDatasourceRef } from '@grafana/runtime/internal';
import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input, Legend } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import {
DashboardInput,
DashboardInputs,
DataSourceInput,
ImportDashboardDTO,
LibraryPanelInputState,
} from '../../types';
import { validateTitle, validateUid } from '../utils/validation';
import { LibraryPanelsList } from './LibraryPanelsList';
interface Props extends Pick<UseFormReturn<ImportDashboardDTO>, 'register' | 'control' | 'getValues' | 'watch'> {
uidReset: boolean;
inputs: DashboardInputs;
errors: FieldErrors<ImportDashboardDTO>;
onCancel: () => void;
onUidReset: () => void;
onSubmit: FormsOnSubmit<ImportDashboardDTO>;
}
export function ImportForm({
register,
errors,
control,
getValues,
uidReset,
inputs,
onUidReset,
onCancel,
onSubmit,
watch,
}: Props) {
const [isSubmitted, setSubmitted] = useState(false);
const watchDataSources = watch('dataSources');
const watchFolder = watch('folder');
/*
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.title || errors.uid)) {
onSubmit(getValues());
}
}, [errors, getValues, isSubmitted, onSubmit]);
const newLibraryPanels = inputs?.libraryPanels?.filter((i) => i.state === LibraryPanelInputState.New) ?? [];
const existingLibraryPanels = inputs?.libraryPanels?.filter((i) => i.state === LibraryPanelInputState.Exists) ?? [];
return (
<>
<Legend>
<Trans i18nKey="manage-dashboards.import-dashboard-form.options">Options</Trans>
</Legend>
<Stack direction="column" gap={2}>
<Field
label={t('manage-dashboards.import-dashboard-form.label-name', 'Name')}
invalid={!!errors.title}
error={errors.title?.message}
noMargin
>
<Input
{...register('title', {
required: 'Name is required',
validate: async (v: string) => await validateTitle(v, getValues().folder.uid),
})}
type="text"
data-testid={selectors.components.ImportDashboardForm.name}
/>
</Field>
<Field label={t('manage-dashboards.import-dashboard-form.label-folder', 'Folder')} noMargin>
<Controller
render={({ field: { ref, value, onChange, ...field } }) => (
<FolderPicker {...field} onChange={(uid, title) => onChange({ uid, title })} value={value.uid} />
)}
name="folder"
control={control}
/>
</Field>
<Field
label={t('manage-dashboards.import-dashboard-form.label-unique-identifier-uid', 'Unique identifier (UID)')}
description={t(
'manage-dashboards.import-dashboard-form.description-unique-identifier-uid',
'The unique identifier (UID) of a dashboard can be used for uniquely identify a dashboard between multiple Grafana installs. The UID allows having consistent URLs for accessing dashboards so changing the title of a dashboard will not break any bookmarked links to that dashboard.'
)}
invalid={!!errors.uid}
error={errors.uid?.message}
noMargin
>
<>
{!uidReset ? (
<Input
disabled
{...register('uid', { validate: async (v: string) => await validateUid(v) })}
addonAfter={
!uidReset && (
<Button onClick={onUidReset}>
<Trans i18nKey="manage-dashboards.import-dashboard-form.change-uid">Change uid</Trans>
</Button>
)
}
/>
) : (
<Input {...register('uid', { required: true, validate: async (v: string) => await validateUid(v) })} />
)}
</>
</Field>
{inputs.dataSources &&
inputs.dataSources.map((input: DataSourceInput, index: number) => {
if (input.pluginId === ExpressionDatasourceRef.type) {
return null;
}
const dataSourceOption = `dataSources.${index}` as const;
const current = watchDataSources ?? [];
return (
<Field
label={input.name}
description={input.description}
key={dataSourceOption}
invalid={errors.dataSources && !!errors.dataSources[index]}
error={errors.dataSources && errors.dataSources[index] && 'A data source is required'}
noMargin
>
<Controller
name={dataSourceOption}
render={({ field: { ref, ...field } }) => (
<DataSourcePicker
{...field}
noDefault={true}
placeholder={input.info}
pluginId={input.pluginId}
current={current[index]?.uid}
/>
)}
control={control}
rules={{ required: true }}
/>
</Field>
);
})}
{inputs.constants &&
inputs.constants.map((input: DashboardInput, index) => {
const constantIndex = `constants.${index}` as const;
return (
<Field
label={input.label}
error={errors.constants && errors.constants[index] && `${input.label} needs a value`}
invalid={errors.constants && !!errors.constants[index]}
key={constantIndex}
noMargin
>
<Input {...register(constantIndex, { required: true })} defaultValue={input.value} />
</Field>
);
})}
<LibraryPanelsList
inputs={newLibraryPanels}
label={t('manage-dashboards.import-dashboard-form.label-new-library-panels', 'New library panels')}
description={t(
'manage-dashboards.import-dashboard-form.description-library-panels-imported',
'List of new library panels that will get imported.'
)}
folderName={watchFolder.title}
/>
<LibraryPanelsList
inputs={existingLibraryPanels}
label={t('manage-dashboards.import-dashboard-form.label-existing-library-panels', 'Existing library panels')}
description={t(
'manage-dashbaords.import-dashboard-form.description-existing-library-panels',
'List of existing library panels. These panels are not affected by the import.'
)}
folderName={watchFolder.title}
/>
<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}>
<Trans i18nKey="manage-dashboards.import-dashboard-form.cancel">Cancel</Trans>
</Button>
</Stack>
</Stack>
</>
);
}
function getButtonVariant(errors: FormFieldErrors<ImportDashboardDTO>) {
return errors && (errors.title || errors.uid) ? 'destructive' : 'primary';
}
function getButtonText(errors: FormFieldErrors<ImportDashboardDTO>) {
return errors && (errors.title || errors.uid) ? 'Import (Overwrite)' : 'Import';
}

View file

@ -0,0 +1,48 @@
import { locationService } from '@grafana/runtime';
import { isDashboardV1Spec, isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { DashboardInputs, DashboardSource } from '../../types';
import { ImportOverviewV1 } from './ImportOverviewV1';
import { ImportOverviewV2 } from './ImportOverviewV2';
type Props = {
dashboard: unknown;
inputs: DashboardInputs;
meta: { updatedAt: string; orgName: string };
source: DashboardSource;
onCancel: () => void;
};
export function ImportOverview({ dashboard, inputs, meta, source, onCancel }: Props) {
const searchObj = locationService.getSearchObject();
const folderUid = searchObj.folderUid ? String(searchObj.folderUid) : '';
if (isDashboardV2Spec(dashboard)) {
return (
<ImportOverviewV2
dashboard={dashboard}
inputs={inputs}
meta={meta}
source={source}
folderUid={folderUid}
onCancel={onCancel}
/>
);
}
if (isDashboardV1Spec(dashboard)) {
return (
<ImportOverviewV1
dashboard={dashboard}
inputs={inputs}
meta={meta}
source={source}
folderUid={folderUid}
onCancel={onCancel}
/>
);
}
return null;
}

View file

@ -0,0 +1,113 @@
import { useState } from 'react';
import { AppEvents, locationUtil } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema/dist/esm/veneer/dashboard.types';
import { appEvents } from 'app/core/app_events';
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 { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { addLibraryPanel } from 'app/features/library-panels/state/api';
import { DashboardInputs, DashboardSource, ImportDashboardDTO, LibraryPanelInputState } from '../../types';
import { applyV1Inputs } from '../utils/inputs';
import { GcomDashboardInfo } from './GcomDashboardInfo';
import { ImportForm } from './ImportForm';
const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported';
type Props = {
dashboard: Dashboard;
inputs: DashboardInputs;
meta: { updatedAt: string; orgName: string };
source: DashboardSource;
folderUid: string;
onCancel: () => void;
};
export function ImportOverviewV1({ dashboard, inputs, meta, source, folderUid, onCancel }: Props) {
const [uidReset, setUidReset] = useState(false);
const folder = { uid: folderUid };
async function onSubmit(form: ImportDashboardDTO) {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
try {
const dashboardWithDataSources = applyV1Inputs(dashboard, inputs, form);
// Import new library panels first
const newLibraryPanels = inputs.libraryPanels.filter((lp) => lp.state === LibraryPanelInputState.New);
for (const lp of newLibraryPanels) {
const libPanelWithPanelModel = new PanelModel(lp.model.model);
let { scopedVars, ...panelSaveModel } = libPanelWithPanelModel.getSaveModel();
panelSaveModel = {
libraryPanel: {
name: lp.model.name,
uid: lp.model.uid,
},
...panelSaveModel,
};
try {
await addLibraryPanel(panelSaveModel, form.folder.uid);
} catch (error) {
appEvents.emit(AppEvents.alertWarning, [
'Library panel import failed',
`Could not import library panel "${lp.model.name}". It may already exist.`,
]);
}
}
const dashboardK8SPayload: SaveDashboardCommand<Dashboard> = {
dashboard: dashboardWithDataSources,
k8s: {
annotations: {
'grafana.app/folder': form.folder.uid,
},
},
};
const result = await getDashboardAPI('v1').saveDashboard(dashboardK8SPayload);
if (result.url) {
const dashboardUrl = locationUtil.stripBaseFromUrl(result.url);
locationService.push(dashboardUrl);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
appEvents.emit(AppEvents.alertError, ['Dashboard import failed', message]);
}
}
return (
<>
{source === DashboardSource.Gcom && (
<GcomDashboardInfo gnetId={dashboard.gnetId} orgName={meta.orgName} updatedAt={meta.updatedAt} />
)}
<Form
onSubmit={onSubmit}
defaultValues={{ ...dashboard, constants: [], dataSources: [], elements: [], folder }}
validateOnMount
validateFieldsOnMount={['title', 'uid']}
validateOn="onChange"
>
{({ register, errors, control, watch, getValues }) => (
<ImportForm
register={register}
errors={errors}
control={control}
getValues={getValues}
uidReset={uidReset}
inputs={inputs}
onCancel={onCancel}
onUidReset={() => setUidReset(true)}
onSubmit={onSubmit}
watch={watch}
/>
)}
</Form>
</>
);
}

View file

@ -0,0 +1,80 @@
import { AppEvents, locationUtil } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { appEvents } from 'app/core/app_events';
import { Form } from 'app/core/components/Form/Form';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardInputs, DashboardSource, ImportFormDataV2 } from '../../types';
import { applyV2Inputs } from '../utils/inputs';
import { GcomDashboardInfo } from './GcomDashboardInfo';
import { ImportDashboardFormV2 } from './ImportDashboardFormV2';
const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported';
type Props = {
dashboard: DashboardV2Spec;
inputs: DashboardInputs;
meta: { updatedAt: string; orgName: string };
source: DashboardSource;
folderUid: string;
onCancel: () => void;
};
export function ImportOverviewV2({ dashboard, inputs, meta, source, folderUid, onCancel }: Props) {
async function onSubmit(form: ImportFormDataV2) {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
try {
const dashboardWithDataSources = {
...applyV2Inputs(dashboard, form),
title: form.dashboard.title,
};
const result = await getDashboardAPI('v2').saveDashboard({
...form,
dashboard: dashboardWithDataSources,
});
if (result.url) {
const dashboardUrl = locationUtil.stripBaseFromUrl(result.url);
locationService.push(dashboardUrl);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
appEvents.emit(AppEvents.alertError, ['Dashboard import failed', message]);
}
}
return (
<>
{source === DashboardSource.Gcom && (
<GcomDashboardInfo gnetId={undefined} orgName={meta.orgName} updatedAt={meta.updatedAt} />
)}
<Form<ImportFormDataV2>
onSubmit={onSubmit}
defaultValues={{
dashboard: dashboard,
folderUid: folderUid,
k8s: { annotations: { 'grafana.app/folder': folderUid } },
}}
validateOnMount
validateOn="onChange"
>
{({ register, errors, control, watch, getValues }) => (
<ImportDashboardFormV2
register={register}
inputs={inputs}
errors={errors}
control={control}
getValues={getValues}
onCancel={onCancel}
onSubmit={onSubmit}
watch={watch}
/>
)}
</Form>
</>
);
}

View file

@ -0,0 +1,155 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import {
Button,
Field,
Input,
TextArea,
FileDropzone,
DropzoneFile,
FileDropzoneDefaultChildren,
LinkButton,
TextLink,
Label,
Stack,
useStyles2,
} from '@grafana/ui';
import { Form } from 'app/core/components/Form/Form';
import { validateDashboardJson, validateGcomDashboard } from '../utils/validation';
const JSON_PLACEHOLDER = `{
"title": "Example - Repeating Dictionary variables",
"uid": "_0HnEoN4z",
"panels": [...]
...
}
`;
type Props = {
onFileUpload: (result: string | ArrayBuffer | null) => void;
onGcomSubmit: (formData: { gcomDashboard: string }) => void;
onJsonSubmit: (formData: { dashboardJson: string }) => void;
};
export function ImportSourceForm({ onFileUpload, onGcomSubmit, onJsonSubmit }: Props) {
const styles = useStyles2(getStyles);
// Do not display upload file list
const fileListRenderer = (_file: DropzoneFile, _removeFile: (file: DropzoneFile) => void) => null;
// Link component for Trans interpolation - the text is intentionally untranslated as it's a URL/brand name
const gcomLink = (
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
<TextLink variant="bodySmall" href="https://grafana.com/grafana/dashboards/" external>
grafana.com/dashboards
</TextLink>
);
return (
<>
<div className={styles.option}>
<FileDropzone
options={{ multiple: false, accept: ['.json', '.txt'] }}
readAs="readAsText"
fileListRenderer={fileListRenderer}
onLoad={onFileUpload}
>
<FileDropzoneDefaultChildren
primaryText={t('dashboard-import.file-dropzone.primary-text', 'Upload dashboard JSON file')}
secondaryText={t('dashboard-import.file-dropzone.secondary-text', 'Drag and drop here or click to browse')}
/>
</FileDropzone>
</div>
<div className={styles.option}>
<Form onSubmit={onGcomSubmit} defaultValues={{ gcomDashboard: '' }}>
{({ register, errors }) => (
<Field
label={
<Label className={styles.labelWithLink} htmlFor="url-input">
<span>
<Trans i18nKey="dashboard-import.gcom-field.label" components={{ link: gcomLink }}>
{'Find and import dashboards for common applications at <link />'}
</Trans>
</span>
</Label>
}
invalid={!!errors.gcomDashboard}
error={errors.gcomDashboard?.message}
noMargin
>
<Input
id="url-input"
placeholder={t('dashboard-import.gcom-field.placeholder', 'Grafana.com dashboard URL or ID')}
type="text"
{...register('gcomDashboard', {
required: t(
'dashboard-import.gcom-field.validation-required',
'A Grafana dashboard URL or ID is required'
),
validate: validateGcomDashboard,
})}
addonAfter={
<Button type="submit">
<Trans i18nKey="dashboard-import.gcom-field.load-button">Load</Trans>
</Button>
}
/>
</Field>
)}
</Form>
</div>
<div className={styles.option}>
<Form onSubmit={onJsonSubmit} defaultValues={{ dashboardJson: '' }}>
{({ register, errors }) => (
<Stack direction="column" gap={2}>
<Field
label={t('dashboard-import.json-field.label', 'Import via dashboard JSON model')}
invalid={!!errors.dashboardJson}
error={errors.dashboardJson?.message}
noMargin
>
<TextArea
{...register('dashboardJson', {
required: t('dashboard-import.json-field.validation-required', 'Need a dashboard JSON model'),
validate: validateDashboardJson,
})}
data-testid={selectors.components.DashboardImportPage.textarea}
id="dashboard-json-textarea"
rows={10}
placeholder={JSON_PLACEHOLDER}
/>
</Field>
<Stack>
<Button type="submit" data-testid={selectors.components.DashboardImportPage.submit}>
<Trans i18nKey="dashboard-import.form-actions.load">Load</Trans>
</Button>
<LinkButton variant="secondary" href={`${config.appSubUrl}/dashboards`}>
<Trans i18nKey="dashboard-import.form-actions.cancel">Cancel</Trans>
</LinkButton>
</Stack>
</Stack>
)}
</Form>
</div>
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
option: css({
marginBottom: theme.spacing(4),
maxWidth: '600px',
}),
labelWithLink: css({
maxWidth: '100%',
}),
};
}

View file

@ -0,0 +1,66 @@
import { css } from '@emotion/css';
import { ReactElement } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, useStyles2 } from '@grafana/ui';
import { LibraryPanelCard } from '../../../library-panels/components/LibraryPanelCard/LibraryPanelCard';
import { LibraryElementDTO } from '../../../library-panels/types';
import { LibraryPanelInput, LibraryPanelInputState } from '../../types';
interface Props {
inputs: LibraryPanelInput[];
label: string;
description: string;
folderName?: string;
}
const DEFAULT_FOLDER_NAME = 'Dashboards';
export function LibraryPanelsList({ inputs, label, description, folderName }: Props): ReactElement | null {
const styles = useStyles2(getStyles);
if (!Boolean(inputs?.length)) {
return null;
}
return (
<div className={styles.spacer}>
<Field label={label} description={description} noMargin>
<>
{inputs.map((input, index) => {
const libraryPanelIndex = `elements[${index}]`;
// For new panels, override folderName in meta; existing panels use model as-is
const libraryPanel: LibraryElementDTO =
input.state === LibraryPanelInputState.New && input.model.meta
? {
...input.model,
meta: {
...input.model.meta,
folderName: folderName ?? input.model.meta.folderName ?? DEFAULT_FOLDER_NAME,
},
}
: input.model;
return (
<div className={styles.item} key={libraryPanelIndex}>
<LibraryPanelCard libraryPanel={libraryPanel} onClick={() => undefined} />
</div>
);
})}
</>
</Field>
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
spacer: css({
marginBottom: theme.spacing(2),
}),
item: css({
marginBottom: theme.spacing(1),
}),
};
}

View file

@ -0,0 +1,316 @@
import { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { AppEvents, LoadingState, NavModelItem } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { Alert, Button, Spinner, Stack } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { Form } from 'app/core/components/Form/Form';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { isRecord } from 'app/core/utils/isRecord';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { dispatch } from 'app/store/store';
import { StoreState } from 'app/types/store';
import { cleanUpAction } from '../../../../core/actions/cleanUp';
import { ExportFormat } from '../../../dashboard/api/types';
import { DashboardSource, ImportDashboardDTO } from '../../types';
import { GcomDashboardInfo } from '../components/GcomDashboardInfo';
import { ImportForm } from '../components/ImportForm';
import { ImportSourceForm } from '../components/ImportSourceForm';
import { detectExportFormat } from '../utils/inputs';
import {
clearLoadedDashboard,
fetchGcomDashboard,
importDashboard,
importDashboardJson,
importDashboardV2Json,
} from './actions';
import { initialImportDashboardState } from './reducers';
function getV1ResourceSpec(dashboard: unknown): Record<string, unknown> | undefined {
if (!isRecord(dashboard) || !('spec' in dashboard)) {
return undefined;
}
const spec = dashboard.spec;
if (!isRecord(spec) || isDashboardV2Spec(spec)) {
return undefined;
}
return spec;
}
const IMPORT_STARTED_EVENT_NAME = 'dashboard_import_loaded';
const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported';
function ImportResourceFormatError({ format, onCancel }: { format: ExportFormat; onCancel: () => void }) {
const errorMessage =
format === ExportFormat.V1Resource
? t(
'manage-dashboards.import-resource-format-error.v1-message',
'This dashboard is in Kubernetes v1 resource format and cannot be imported when Kubernetes dashboards feature is disabled. Please enable the kubernetesDashboards feature toggle to import this dashboard.'
)
: t(
'manage-dashboards.import-resource-format-error.v2-message',
'This dashboard is in v2 resource format and cannot be imported when Kubernetes dashboards feature is disabled. Please enable the kubernetesDashboards feature toggle to import this dashboard.'
);
return (
<Stack direction="column" gap={2}>
<Alert title={t('manage-dashboards.import-resource-format-error.title', 'Unsupported format')} severity="error">
{errorMessage}
</Alert>
<Stack>
<Button variant="secondary" onClick={onCancel}>
<Trans i18nKey="manage-dashboards.import-resource-format-error.cancel">Cancel</Trans>
</Button>
</Stack>
</Stack>
);
}
const overviewMapStateToProps = (state: StoreState) => {
const searchObj = locationService.getSearchObject();
return {
dashboard: state.importDashboard.dashboard,
meta: state.importDashboard.meta,
source: state.importDashboard.source,
inputs: state.importDashboard.inputs,
folder: searchObj.folderUid ? { uid: String(searchObj.folderUid) } : { uid: '' },
};
};
const overviewMapDispatchToProps = {
clearLoadedDashboard,
importDashboard,
};
const overviewConnector = connect(overviewMapStateToProps, overviewMapDispatchToProps);
type OverviewProps = ConnectedProps<typeof overviewConnector>;
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
class ImportOverviewUnConnected extends PureComponent<OverviewProps, { uidReset: boolean }> {
state = { uidReset: false };
onSubmit = (form: ImportDashboardDTO) => {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
this.props.importDashboard(form);
};
onCancel = () => {
this.props.clearLoadedDashboard();
};
onUidReset = () => {
this.setState({ uidReset: true });
};
render() {
const { dashboard, inputs, meta, source, folder } = this.props;
const { uidReset } = this.state;
return (
<>
{source === DashboardSource.Gcom && (
<GcomDashboardInfo gnetId={dashboard.gnetId} orgName={meta.orgName} updatedAt={meta.updatedAt} />
)}
<Form
onSubmit={this.onSubmit}
defaultValues={{ ...dashboard, constants: [], dataSources: [], elements: [], folder: folder }}
validateOnMount
validateFieldsOnMount={['title', 'uid']}
validateOn="onChange"
>
{({ register, errors, control, watch, getValues }) => (
<ImportForm
register={register}
errors={errors}
control={control}
getValues={getValues}
uidReset={uidReset}
inputs={inputs}
onCancel={this.onCancel}
onUidReset={this.onUidReset}
onSubmit={this.onSubmit}
watch={watch}
/>
)}
</Form>
</>
);
}
}
const ImportOverview = overviewConnector(ImportOverviewUnConnected);
type DashboardImportPageRouteSearchParams = {
gcomDashboardId?: string;
};
type OwnProps = GrafanaRouteComponentProps<{}, DashboardImportPageRouteSearchParams>;
const mapStateToProps = (state: StoreState) => ({
loadingState: state.importDashboard.state,
dashboard: state.importDashboard.dashboard,
});
const mapDispatchToProps = {
fetchGcomDashboard,
importDashboardJson,
clearLoadedDashboard,
cleanUpAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
class UnthemedDashboardImportLegacy extends PureComponent<Props> {
constructor(props: Props) {
super(props);
const { gcomDashboardId } = this.props.queryParams;
if (gcomDashboardId) {
this.handleGcomSubmit({ gcomDashboard: gcomDashboardId });
return;
}
}
componentWillUnmount() {
this.props.cleanUpAction({ cleanupAction: (state) => (state.importDashboard = initialImportDashboardState) });
}
handleFileUpload = (result: string | ArrayBuffer | null) => {
reportInteraction(IMPORT_STARTED_EVENT_NAME, {
import_source: 'json_uploaded',
});
try {
const json = JSON.parse(String(result));
if (json.spec?.elements) {
return dispatch(importDashboardV2Json(json.spec));
} else if (json.elements) {
return dispatch(importDashboardV2Json(json));
}
const v1ResourceSpec = getV1ResourceSpec(json);
if (v1ResourceSpec) {
return this.props.importDashboardJson(v1ResourceSpec);
}
this.props.importDashboardJson(json);
} catch (error) {
if (error instanceof Error) {
appEvents.emit(AppEvents.alertError, ['Import failed', 'JSON -> JS Serialization failed: ' + error.message]);
}
return;
}
};
handleJsonSubmit = (formData: { dashboardJson: string }) => {
reportInteraction(IMPORT_STARTED_EVENT_NAME, {
import_source: 'json_pasted',
});
const dashboard = JSON.parse(formData.dashboardJson);
if ((dashboard.spec?.elements || dashboard.elements) && !config.featureToggles.dashboardNewLayouts) {
return appEvents.emit(AppEvents.alertError, [
'Import failed',
'Dashboard using new layout cannot be imported because the feature is not enabled',
]);
}
const format = detectExportFormat(dashboard);
if (format === ExportFormat.V2Resource && dashboard.spec?.elements) {
return dispatch(importDashboardV2Json(dashboard.spec));
}
if (format === ExportFormat.V2Resource && dashboard.elements) {
return dispatch(importDashboardV2Json(dashboard));
}
const v1ResourceSpec = getV1ResourceSpec(dashboard);
if (v1ResourceSpec) {
return this.props.importDashboardJson(v1ResourceSpec);
}
this.props.importDashboardJson(dashboard);
};
handleGcomSubmit = (formData: { gcomDashboard: string }) => {
reportInteraction(IMPORT_STARTED_EVENT_NAME, {
import_source: 'gcom',
});
let dashboardId;
const match = /(^\d+$)|dashboards\/(\d+)/.exec(formData.gcomDashboard);
if (match && match[1]) {
dashboardId = match[1];
} else if (match && match[2]) {
dashboardId = match[2];
}
if (dashboardId) {
this.props.fetchGcomDashboard(dashboardId);
}
};
pageNav: NavModelItem = {
text: t('manage-dashboards.unthemed-dashboard-import.text.import-dashboard', 'Import dashboard'),
subTitle: t(
'manage-dashboards.unthemed-dashboard-import.subTitle.import-dashboard-from-file-or-grafanacom',
'Import dashboard from file or Grafana.com'
),
};
getDashboardOverview() {
const { loadingState, dashboard } = this.props;
if (loadingState === LoadingState.Done) {
const format = detectExportFormat(dashboard);
// k8s disabled but resource format -> show error
if (format === ExportFormat.V1Resource || format === ExportFormat.V2Resource) {
return <ImportResourceFormatError format={format} onCancel={this.props.clearLoadedDashboard} />;
}
// k8s disabled + classic -> legacy redux path
return <ImportOverview />;
}
return null;
}
render() {
const { loadingState } = this.props;
return (
<Page navId="dashboards/browse" pageNav={this.pageNav}>
<Page.Contents>
{loadingState === LoadingState.Loading && (
<Stack direction={'column'} justifyContent="center">
<Stack justifyContent="center">
<Spinner size="xxl" />
</Stack>
</Stack>
)}
{[LoadingState.Error, LoadingState.NotStarted].includes(loadingState) && (
<ImportSourceForm
onFileUpload={this.handleFileUpload}
onGcomSubmit={this.handleGcomSubmit}
onJsonSubmit={this.handleJsonSubmit}
/>
)}
{this.getDashboardOverview()}
</Page.Contents>
</Page>
);
}
}
export const DashboardImportLegacy = connector(UnthemedDashboardImportLegacy);
DashboardImportLegacy.displayName = 'DashboardImportLegacy';

View file

@ -12,19 +12,14 @@ import {
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { getLibraryPanel } from 'app/features/library-panels/state/api';
import { PanelModel } from '../../dashboard/state/PanelModel';
import { LibraryElementDTO } from '../../library-panels/types';
import { DashboardJson } from '../types';
import { PanelModel } from '../../../dashboard/state/PanelModel';
import { LibraryElementDTO } from '../../../library-panels/types';
import { DashboardJson, DataSourceInput, ImportDashboardDTO, InputType } from '../../types';
import { getLibraryPanelInputs } from '../utils/inputs';
import { validateDashboardJson } from '../utils/validation';
import {
getLibraryPanelInputs,
importDashboard,
processDashboard,
processV2DatasourceInput,
processV2Datasources,
} from './actions';
import { DataSourceInput, ImportDashboardDTO, initialImportDashboardState, InputType } from './reducers';
import { importDashboard, processDashboard, processV2DatasourceInput, processV2Datasources } from './actions';
import { initialImportDashboardState } from './reducers';
jest.mock('app/features/library-panels/state/api');
const mocks = {

View file

@ -1,3 +1,5 @@
// Legacy Redux actions - will be removed when kubernetesDashboards feature is removed
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DataSourceInstanceSettings } from '@grafana/data';
import { getBackendSrv, getDataSourceSrv, isFetchError } from '@grafana/runtime';
import {
@ -17,21 +19,15 @@ import {
InputUsage,
LibraryElementExport,
LibraryPanel,
} from '../../dashboard/components/DashExportModal/DashboardExporter';
import { getLibraryPanel } from '../../library-panels/state/api';
import { LibraryElementDTO, LibraryElementKind } from '../../library-panels/types';
import { DashboardJson } from '../types';
} from '../../../dashboard/components/DashExportModal/DashboardExporter';
import { DataSourceInput, ImportDashboardDTO, InputType, LibraryPanelInputState, DashboardJson } from '../../types';
import { getLibraryPanelInputs } from '../utils/inputs';
import {
clearDashboard,
DataSourceInput,
fetchDashboard,
fetchFailed,
ImportDashboardDTO,
ImportDashboardState,
InputType,
LibraryPanelInput,
LibraryPanelInputState,
setGcomDashboard,
setInputs,
setJsonDashboard,
@ -188,52 +184,6 @@ export function processV2Datasources(dashboard: DashboardV2Spec): ThunkResult<vo
};
}
export async function getLibraryPanelInputs(dashboardJson?: {
__elements?: Record<string, LibraryElementExport>;
}): Promise<LibraryPanelInput[]> {
if (!dashboardJson || !dashboardJson.__elements) {
return [];
}
const libraryPanelInputs: LibraryPanelInput[] = [];
for (const element of Object.values(dashboardJson.__elements)) {
if (element.kind !== LibraryElementKind.Panel) {
continue;
}
const model = element.model;
const { type, description } = model;
const { uid, name } = element;
const input: LibraryPanelInput = {
model: {
model,
uid,
name,
version: 0,
type,
kind: LibraryElementKind.Panel,
description,
} as LibraryElementDTO,
state: LibraryPanelInputState.New,
};
try {
const panelInDb = await getLibraryPanel(uid, true);
input.state = LibraryPanelInputState.Exists;
input.model = panelInDb;
} catch (e: any) {
if (e.status !== 404) {
throw e;
}
}
libraryPanelInputs.push(input);
}
return libraryPanelInputs;
}
export function clearLoadedDashboard(): ThunkResult<void> {
return (dispatch) => {
dispatch(clearDashboard());

View file

@ -1,18 +1,14 @@
import { LoadingState } from '@grafana/data';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { LibraryElementDTO } from '../../library-panels/types';
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import { LibraryElementDTO } from '../../../library-panels/types';
import { DashboardSource, DataSourceInput, InputType, LibraryPanelInput, LibraryPanelInputState } from '../../types';
import {
clearDashboard,
DashboardSource,
DataSourceInput,
importDashboardReducer,
ImportDashboardState,
initialImportDashboardState,
InputType,
LibraryPanelInput,
LibraryPanelInputState,
setGcomDashboard,
setInputs,
setJsonDashboard,

View file

@ -1,60 +1,12 @@
// Legacy Redux slice - will be removed when kubernetesDashboards feature is removed
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createSlice, Draft, PayloadAction } from '@reduxjs/toolkit';
import { DataSourceInstanceSettings, LoadingState } from '@grafana/data';
import { LoadingState } from '@grafana/data';
import { LibraryElementDTO } from '../../library-panels/types';
export enum DashboardSource {
Gcom = 0,
Json = 1,
}
export interface ImportDashboardDTO {
title: string;
uid: string;
gnetId: string;
constants: string[];
dataSources: DataSourceInstanceSettings[];
elements: LibraryElementDTO[];
folder: { uid: string; title?: string };
}
export enum InputType {
DataSource = 'datasource',
Constant = 'constant',
LibraryPanel = 'libraryPanel',
}
export enum LibraryPanelInputState {
New = 'new',
Exists = 'exists',
Different = 'different',
}
export interface DashboardInput {
name: string;
label: string;
description?: string;
info: string;
value: string;
type: InputType;
}
export interface DataSourceInput extends DashboardInput {
pluginId: string;
}
export interface LibraryPanelInput {
model: LibraryElementDTO;
state: LibraryPanelInputState;
}
export interface DashboardInputs {
dataSources: DataSourceInput[];
constants: DashboardInput[];
libraryPanels: LibraryPanelInput[];
}
import { DashboardInputs, DashboardSource, InputType, LibraryPanelInput } from '../../types';
// Legacy-only type - Redux state shape
export interface ImportDashboardState {
meta: { updatedAt: string; orgName: string };
dashboard: any;
@ -67,6 +19,7 @@ export const initialImportDashboardState: ImportDashboardState = {
meta: { updatedAt: '', orgName: '' },
dashboard: {},
source: DashboardSource.Json,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
inputs: {} as DashboardInputs,
state: LoadingState.NotStarted,
};

View file

@ -0,0 +1,817 @@
import { DataSourceInstanceSettings } from '@grafana/data';
import {
AnnotationQueryKind,
PanelKind,
QueryVariableKind,
Spec as DashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Dashboard, Panel, VariableModel } from '@grafana/schema/dist/esm/veneer/dashboard.types';
import { ExportFormat } from 'app/features/dashboard/api/types';
import { DashboardInputs, ImportDashboardDTO, ImportFormDataV2, InputType } from '../../types';
import {
applyV1Inputs,
applyV2Inputs,
detectExportFormat,
extractV1Inputs,
extractV2Inputs,
isVariableRef,
replaceDatasourcesInDashboard,
DatasourceMappings,
} from './inputs';
// Mock external dependencies
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getList: jest.fn().mockReturnValue([{ uid: 'ds-1', name: 'Prometheus', type: 'prometheus' }]),
}),
}));
jest.mock('../../../library-panels/state/api', () => ({
getLibraryPanel: jest.fn().mockRejectedValue({ status: 404 }),
}));
// Test data constants
const emptyInputs: DashboardInputs = { dataSources: [], constants: [], libraryPanels: [] };
const sampleV1Inputs: DashboardInputs = {
dataSources: [
{
name: 'DS',
label: 'DS',
description: 'test',
info: 'info',
value: '',
type: InputType.DataSource,
pluginId: 'prometheus',
},
],
constants: [],
libraryPanels: [],
};
// Helper functions for creating test data
function createV1DashboardWithInputs(
inputs: Array<{
name: string;
type: InputType;
label: string;
description?: string;
pluginId?: string;
value?: string;
}>
) {
return {
title: 'Test Dashboard',
__inputs: inputs,
};
}
describe('detectExportFormat', () => {
it.each([
['v2 resource', { kind: 'DashboardWithAccessInfo', spec: { elements: {} } }, ExportFormat.V2Resource],
['v2 spec (raw)', { elements: {}, layout: {} }, ExportFormat.V2Resource],
['v1 resource', { kind: 'DashboardWithAccessInfo', spec: { title: 'v1' } }, ExportFormat.V1Resource],
['classic', { title: 'v1' }, ExportFormat.Classic],
])('detects %s format', (_name, dashboard, expected) => {
expect(detectExportFormat(dashboard)).toBe(expected);
});
});
// Test helper types for accessing nested properties
interface PanelWithTargets extends Panel {
targets?: Array<{ datasource?: { uid?: string } }>;
}
interface QueryVariableModel extends VariableModel {
datasource?: { uid?: string };
}
interface DatasourceVariableModel {
type: string;
current?: { value?: string; text?: string; selected?: boolean };
}
// Removed duplicate constants - now defined at top of file
describe('extractV1Inputs', () => {
it.each([
['non-object dashboard', null],
['dashboard without __inputs', { title: 'Test Dashboard' }],
])('should return empty inputs for %s', async (_name, dashboard) => {
const result = await extractV1Inputs(dashboard);
expect(result).toEqual(emptyInputs);
});
it('should extract datasource inputs from __inputs array', async () => {
const dashboard = createV1DashboardWithInputs([
{
name: 'DS_PROMETHEUS',
type: InputType.DataSource,
label: 'Prometheus',
description: 'Prometheus datasource',
pluginId: 'prometheus',
},
]);
const result = await extractV1Inputs(dashboard);
expect(result.dataSources).toHaveLength(1);
expect(result.dataSources[0].name).toBe('DS_PROMETHEUS');
expect(result.dataSources[0].pluginId).toBe('prometheus');
expect(result.dataSources[0].type).toBe(InputType.DataSource);
});
it('should extract constant inputs from __inputs array', async () => {
const dashboard = createV1DashboardWithInputs([
{
name: 'VAR_CONSTANT',
type: InputType.Constant,
label: 'My Constant',
description: 'A constant value',
value: 'default-value',
},
]);
const result = await extractV1Inputs(dashboard);
expect(result.constants).toHaveLength(1);
expect(result.constants[0].name).toBe('VAR_CONSTANT');
expect(result.constants[0].value).toBe('default-value');
expect(result.constants[0].type).toBe(InputType.Constant);
});
it('should add default info for constants without description', async () => {
const dashboard = {
title: 'Test Dashboard',
__inputs: [
{
name: 'VAR_CONSTANT',
type: InputType.Constant,
label: 'My Constant',
value: '',
},
],
};
const result = await extractV1Inputs(dashboard);
expect(result.constants[0].info).toBe('Specify a string constant');
});
it('should extract multiple inputs of different types', async () => {
const dashboard = createV1DashboardWithInputs([
{
name: 'DS_PROMETHEUS',
type: InputType.DataSource,
label: 'Prometheus',
pluginId: 'prometheus',
},
{
name: 'DS_LOKI',
type: InputType.DataSource,
label: 'Loki',
pluginId: 'loki',
},
{
name: 'VAR_NAMESPACE',
type: InputType.Constant,
label: 'Namespace',
value: 'default',
},
]);
const result = await extractV1Inputs(dashboard);
expect(result.dataSources).toHaveLength(2);
expect(result.constants).toHaveLength(1);
});
it('should skip invalid inputs and only process valid ones', async () => {
const dashboard = {
title: 'Test Dashboard',
__inputs: [null, 'invalid', { name: 'VALID', type: InputType.Constant, label: 'Valid', value: 'test' }],
};
const result = await extractV1Inputs(dashboard);
expect(result.constants).toHaveLength(1);
expect(result.constants[0].name).toBe('VALID');
});
it('should skip inputs without a type', async () => {
const dashboard = {
title: 'Test Dashboard',
__inputs: [{ name: 'MISSING_TYPE' }],
};
const result = await extractV1Inputs(dashboard);
expect(result.dataSources).toHaveLength(0);
expect(result.constants).toHaveLength(0);
});
it('should handle empty __inputs array', async () => {
const dashboard = { title: 'Test Dashboard', __inputs: [] };
const result = await extractV1Inputs(dashboard);
expect(result).toEqual(emptyInputs);
});
});
describe('extractV2Inputs', () => {
it('should return empty inputs for non-object dashboard', () => {
expect(extractV2Inputs(null)).toEqual(emptyInputs);
});
it.each([
[
'query variables',
{
elements: {},
variables: [{ kind: 'QueryVariable', spec: { name: 'myvar', query: { group: 'prometheus' } } }],
},
],
[
'annotations',
{
elements: {},
annotations: [{ kind: 'AnnotationQuery', spec: { name: 'Deployments', query: { group: 'prometheus' } } }],
},
],
[
'panel queries',
{
elements: {
'panel-1': {
kind: 'Panel',
spec: {
data: {
kind: 'QueryGroup',
spec: { queries: [{ kind: 'PanelQuery', spec: { query: { group: 'prometheus' } } }] },
},
},
},
},
},
],
])('should collect datasource types from %s', (_source, dashboard) => {
const result = extractV2Inputs(dashboard);
expect(result.dataSources).toHaveLength(1);
expect(result.dataSources[0].pluginId).toBe('prometheus');
});
it('should handle empty dashboard gracefully', () => {
const result = extractV2Inputs({});
expect(result).toEqual(emptyInputs);
});
it('should deduplicate datasource types', () => {
const dashboard = {
elements: {},
variables: [
{ kind: 'QueryVariable', spec: { name: 'var1', query: { group: 'prometheus' } } },
{ kind: 'QueryVariable', spec: { name: 'var2', query: { group: 'prometheus' } } },
],
annotations: [{ spec: { name: 'Deployments', query: { group: 'prometheus' } } }],
};
const result = extractV2Inputs(dashboard);
expect(result.dataSources).toHaveLength(1);
expect(result.dataSources[0].pluginId).toBe('prometheus');
});
it('should collect multiple different datasource types', () => {
const dashboard = {
elements: {},
variables: [
{ kind: 'QueryVariable', spec: { name: 'promvar', query: { group: 'prometheus' } } },
{ kind: 'QueryVariable', spec: { name: 'lokivar', query: { group: 'loki' } } },
],
};
const result = extractV2Inputs(dashboard);
expect(result.dataSources).toHaveLength(2);
expect(result.dataSources.map((ds) => ds.pluginId)).toContain('prometheus');
expect(result.dataSources.map((ds) => ds.pluginId)).toContain('loki');
});
it.each([
[
'non-QueryVariable variables',
{ variables: [{ kind: 'TextVariable', spec: { name: 'textvar', value: 'test' } }] },
],
[
'panels without QueryGroup data',
{ elements: { 'panel-1': { kind: 'Panel', spec: { data: { kind: 'Snapshot', spec: {} } } } } },
],
])('should skip %s', (_name, dashboard) => {
expect(extractV2Inputs(dashboard).dataSources).toHaveLength(0);
});
});
describe('applyV1Inputs', () => {
it('replaces templateized datasources across v1 dashboard elements', () => {
const dashboard = {
title: 'old',
uid: 'old',
schemaVersion: 39,
annotations: {
list: [
{
name: 'anno',
datasource: { uid: '${DS}' },
enable: true,
iconColor: 'red',
target: { limit: 1, matchAny: true, tags: [], type: 'tags' },
},
],
},
panels: [
{
datasource: { uid: '${DS}' },
targets: [{ datasource: { uid: '${DS}' } }],
},
],
templating: {
list: [
{
type: 'query',
datasource: { uid: '${DS}' },
},
{
type: 'datasource',
current: { value: '${DS}', text: '${DS}', selected: true },
},
],
},
} as unknown as Dashboard;
const form: ImportDashboardDTO = {
title: 'new-title',
uid: 'new-uid',
gnetId: '',
constants: [],
dataSources: [{ uid: 'ds-uid', type: 'prometheus', name: 'My DS' } as DataSourceInstanceSettings],
elements: [],
folder: { uid: 'folder' },
};
const result = applyV1Inputs(dashboard, sampleV1Inputs, form);
expect(result.title).toBe('new-title');
expect(result.uid).toBe('new-uid');
expect(result.annotations?.list?.[0].datasource?.uid).toBe('ds-uid');
expect(result.panels?.[0].datasource?.uid).toBe('ds-uid');
const panelWithTargets = result.panels?.[0] as PanelWithTargets;
expect(panelWithTargets.targets?.[0].datasource?.uid).toBe('ds-uid');
const queryVariable = result.templating?.list?.[0] as QueryVariableModel;
expect(queryVariable.datasource?.uid).toBe('ds-uid');
const dsVariable = result.templating?.list?.[1] as DatasourceVariableModel;
expect(dsVariable.current?.value).toBe('ds-uid');
});
});
describe('applyV2Inputs', () => {
it('updates v2 annotations, variables, and panel queries', () => {
const dashboard = {
title: 'old',
elements: {
panel: {
kind: 'Panel',
spec: {
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
query: { group: 'prometheus', datasource: { name: 'old-ds' } },
},
},
],
},
},
},
},
},
annotations: [
{
kind: 'AnnotationQuery',
spec: {
query: { group: 'prometheus', datasource: { name: 'old-ds' } },
},
},
],
variables: [
{
kind: 'QueryVariable',
spec: {
query: { group: 'prometheus', datasource: { name: 'old-ds' } },
},
},
],
} as unknown as DashboardV2Spec;
const form: ImportFormDataV2 = {
dashboard,
folderUid: 'folder',
message: '',
'datasource-prometheus': { uid: 'ds-uid', type: 'prometheus', name: 'My DS' },
};
const result = applyV2Inputs(dashboard, form);
const updatedAnnotation = result.annotations?.[0] as AnnotationQueryKind;
expect(updatedAnnotation.spec.query?.datasource?.name).toBe('ds-uid');
const updatedVariable = result.variables?.[0] as QueryVariableKind;
expect(updatedVariable.spec.query?.datasource?.name).toBe('ds-uid');
const updatedPanel = result.elements.panel as PanelKind;
const queries = updatedPanel.spec.data?.kind === 'QueryGroup' ? updatedPanel.spec.data.spec.queries : [];
const updatedQuery = queries[0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const querySpec = updatedQuery?.spec as any;
expect(querySpec?.query?.datasource?.name).toBe('ds-uid');
});
it('preserves variable references and does not replace them', () => {
const dashboard = {
title: 'old',
elements: {},
annotations: [
{
kind: 'AnnotationQuery',
spec: {
query: { group: 'prometheus', datasource: { name: '${ds}' } },
},
},
],
variables: [],
} as unknown as DashboardV2Spec;
const form: ImportFormDataV2 = {
dashboard,
folderUid: 'folder',
message: '',
'datasource-prometheus': { uid: 'ds-uid', type: 'prometheus', name: 'My DS' },
};
const result = applyV2Inputs(dashboard, form);
const annotation = result.annotations?.[0] as AnnotationQueryKind;
expect(annotation.spec.query?.datasource?.name).toBe('${ds}');
});
});
describe('isVariableRef', () => {
it.each([
{ input: '${ds}', expected: true },
{ input: '$ds', expected: true },
{ input: 'abc123', expected: false },
{ input: undefined, expected: false },
{ input: '', expected: false },
])('returns $expected for $input', ({ input, expected }) => {
expect(isVariableRef(input)).toBe(expected);
});
});
describe('replaceDatasourcesInDashboard', () => {
// @ts-ignore - using minimal test schema
const baseDashboard: DashboardV2Spec = {
title: 'Test Dashboard',
annotations: [],
variables: [],
elements: {},
layout: { kind: 'GridLayout', spec: { items: [] } },
cursorSync: 'Off',
liveNow: false,
editable: true,
preload: false,
links: [],
tags: [],
timeSettings: {
timezone: 'utc',
from: 'now-6h',
to: 'now',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
},
};
const mappings: DatasourceMappings = {
loki: { uid: 'new-loki-uid', type: 'loki', name: 'New Loki' },
prometheus: { uid: 'new-prom-uid', type: 'prometheus', name: 'New Prometheus' },
};
const createPanelWithQuery = (group: string, datasourceName: string) => ({
kind: 'Panel' as const,
spec: {
id: 1,
title: 'Test Panel',
description: '',
links: [],
vizConfig: {
kind: 'VizConfig' as const,
group: 'timeseries',
version: 'v0',
spec: { options: {}, fieldConfig: { defaults: {}, overrides: [] } },
},
data: {
kind: 'QueryGroup' as const,
spec: {
queries: [
{
kind: 'PanelQuery' as const,
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery' as const,
group,
version: 'v0',
datasource: { name: datasourceName },
spec: {},
},
},
},
],
queryOptions: {},
transformations: [],
},
},
},
});
const getPanelQueryDatasourceName = (result: DashboardV2Spec, panelKey = 'panel-1') => {
const panel = result.elements[panelKey];
if (panel.kind === 'Panel' && panel.spec.data?.kind === 'QueryGroup') {
return panel.spec.data.spec.queries[0].spec.query?.datasource?.name;
}
return undefined;
};
const getQueryVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'QueryVariable' ? variable : undefined;
};
const getDatasourceVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'DatasourceVariable' ? variable : undefined;
};
const getAdhocVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'AdhocVariable' ? variable : undefined;
};
const getGroupByVariable = (result: DashboardV2Spec, index = 0) => {
const variable = result.variables?.[index];
return variable?.kind === 'GroupByVariable' ? variable : undefined;
};
describe('panel queries', () => {
it.each([
{ group: 'loki', inputDs: 'old-loki-uid', expectedDs: 'new-loki-uid', desc: 'replaces hardcoded datasource' },
{ group: 'prometheus', inputDs: '${ds}', expectedDs: '${ds}', desc: 'preserves ${ds} variable reference' },
{ group: 'prometheus', inputDs: '$ds', expectedDs: '$ds', desc: 'preserves $ds variable reference' },
{
group: 'elasticsearch',
inputDs: 'es-uid',
expectedDs: 'es-uid',
desc: 'keeps original when no mapping exists',
},
])('$desc', ({ group, inputDs, expectedDs }) => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
elements: { 'panel-1': createPanelWithQuery(group, inputDs) },
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
expect(getPanelQueryDatasourceName(result)).toBe(expectedDs);
});
});
describe('annotations', () => {
const createAnnotation = (group: string, datasourceName: string) => ({
kind: 'AnnotationQuery' as const,
spec: {
name: 'Test Annotation',
enable: true,
hide: false,
iconColor: 'red',
query: {
kind: 'DataQuery' as const,
group,
version: 'v0',
datasource: { name: datasourceName },
spec: {},
},
},
});
it.each([
{ inputDs: 'old-prom-uid', expectedDs: 'new-prom-uid', desc: 'replaces hardcoded datasource' },
{ inputDs: '${ds}', expectedDs: '${ds}', desc: 'preserves variable reference' },
])('$desc', ({ inputDs, expectedDs }) => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
annotations: [createAnnotation('prometheus', inputDs)],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
expect(result.annotations?.[0].spec.query?.datasource?.name).toBe(expectedDs);
});
});
describe('query variable', () => {
const createQueryVariable = (group: string, datasourceName: string) => ({
kind: 'QueryVariable' as const,
spec: {
name: 'test_var',
current: { text: 'All', value: '$__all' },
options: [{ text: 'All', value: '$__all' }],
hide: 'dontHide' as const,
skipUrlSync: false,
multi: false,
includeAll: true,
allowCustomValue: false,
refresh: 'onDashboardLoad' as const,
regex: '',
sort: 'disabled' as const,
query: {
kind: 'DataQuery' as const,
group,
version: 'v0',
datasource: { name: datasourceName },
spec: {},
},
},
});
it('replaces hardcoded datasource and resets options/current', () => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createQueryVariable('prometheus', 'old-prom-uid')],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getQueryVariable(result);
expect(variable).toBeDefined();
expect(variable?.spec.query?.datasource?.name).toBe('new-prom-uid');
expect(variable?.spec.options).toEqual([]);
expect(variable?.spec.current).toEqual({ text: '', value: '' });
expect(variable?.spec.refresh).toBe('onDashboardLoad');
});
it('preserves variable reference and keeps options intact', () => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createQueryVariable('prometheus', '${ds}')],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getQueryVariable(result);
expect(variable?.spec.query?.datasource?.name).toBe('${ds}');
expect(variable?.spec.options).toEqual([{ text: 'All', value: '$__all' }]);
});
});
describe('datasource variable', () => {
const createDatasourceVariable = (pluginId: string, currentValue: string, currentText: string) => ({
kind: 'DatasourceVariable' as const,
spec: {
name: 'ds',
pluginId,
current: { text: currentText, value: currentValue },
options: [],
hide: 'dontHide' as const,
skipUrlSync: false,
multi: false,
includeAll: false,
allowCustomValue: false,
refresh: 'onDashboardLoad' as const,
regex: '',
},
});
it('replaces current value in DatasourceVariable', () => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createDatasourceVariable('prometheus', 'old-prom-uid', 'Old Prometheus')],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getDatasourceVariable(result);
expect(variable).toBeDefined();
expect(variable?.spec.current?.value).toBe('new-prom-uid');
expect(variable?.spec.current?.text).toBe('New Prometheus');
});
});
describe('AdhocVariable', () => {
const createAdhocVariable = (group: string, datasourceName: string) => ({
kind: 'AdhocVariable' as const,
group,
datasource: { name: datasourceName },
spec: {
name: 'Filters',
hide: 'dontHide' as const,
skipUrlSync: false,
allowCustomValue: true,
defaultKeys: [],
filters: [],
baseFilters: [],
},
});
it.each([
{ inputDs: 'old-loki-uid', expectedDs: 'new-loki-uid', desc: 'replaces hardcoded datasource' },
{ inputDs: '${ds}', expectedDs: '${ds}', desc: 'preserves variable reference' },
])('$desc', ({ inputDs, expectedDs }) => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createAdhocVariable('loki', inputDs)],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getAdhocVariable(result);
expect(variable).toBeDefined();
expect(variable?.datasource?.name).toBe(expectedDs);
});
});
describe('GroupBy variable', () => {
const createGroupByVariable = (group: string, datasourceName: string) => ({
kind: 'GroupByVariable' as const,
group,
datasource: { name: datasourceName },
spec: {
name: 'groupby',
hide: 'dontHide' as const,
skipUrlSync: false,
allowCustomValue: false,
multi: false,
options: [],
current: { text: '', value: '' },
},
});
it.each([
{ inputDs: 'old-prom-uid', expectedDs: 'new-prom-uid', desc: 'replaces hardcoded datasource' },
{ inputDs: '${ds}', expectedDs: '${ds}', desc: 'preserves variable reference' },
])('$desc', ({ inputDs, expectedDs }) => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
variables: [createGroupByVariable('prometheus', inputDs)],
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
const variable = getGroupByVariable(result);
expect(variable).toBeDefined();
expect(variable?.datasource?.name).toBe(expectedDs);
});
});
describe('edge cases', () => {
it('handles mixed variable and hardcoded datasources', () => {
// @ts-ignore - using minimal test schema
const dashboard: DashboardV2Spec = {
...baseDashboard,
elements: {
'panel-variable': createPanelWithQuery('prometheus', '${ds}'),
'panel-hardcoded': createPanelWithQuery('loki', 'old-loki-uid'),
},
};
const result = replaceDatasourcesInDashboard(dashboard, mappings);
expect(getPanelQueryDatasourceName(result, 'panel-variable')).toBe('${ds}');
expect(getPanelQueryDatasourceName(result, 'panel-hardcoded')).toBe('new-loki-uid');
});
});
});

View file

@ -0,0 +1,560 @@
import { DataSourceInstanceSettings, VariableModel } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { Panel } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
import { AnnotationQueryKind, Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { AnnotationQuery, Dashboard } from '@grafana/schema/dist/esm/veneer/dashboard.types';
import { isRecord } from 'app/core/utils/isRecord';
import { ExportFormat } from 'app/features/dashboard/api/types';
import { isDashboardV1Resource, isDashboardV2Resource, isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { LibraryElementExport } from '../../../dashboard/components/DashExportModal/DashboardExporter';
import { getLibraryPanel } from '../../../library-panels/state/api';
import { LibraryElementKind } from '../../../library-panels/types';
import {
DashboardInputs,
DataSourceInput,
ImportDashboardDTO,
ImportFormDataV2,
InputType,
LibraryPanelInput,
LibraryPanelInputState,
} from '../../types';
/** Maps datasource type (e.g. "prometheus", "loki") to user-selected datasource from the import form */
export type DatasourceMappings = Record<string, { uid: string; type: string; name?: string }>;
/**
* Detect the dashboard format from input.
* Handles k8s resources (v1/v2), raw specs, and classic dashboards.
*/
export function detectExportFormat(input: unknown): ExportFormat {
if (isDashboardV2Resource(input) || isDashboardV2Spec(input)) {
return ExportFormat.V2Resource;
}
if (isDashboardV1Resource(input)) {
return ExportFormat.V1Resource;
}
return ExportFormat.Classic;
}
function isLibraryElementExport(value: unknown): value is LibraryElementExport {
return (
isRecord(value) &&
typeof value.name === 'string' &&
typeof value.uid === 'string' &&
typeof value.kind === 'number' &&
isRecord(value.model)
);
}
function hasUid(query: Record<string, unknown> | {}): query is { uid: string } {
return 'uid' in query && typeof query['uid'] === 'string';
}
/**
* Extract library panel inputs from dashboard __elements
*/
export async function getLibraryPanelInputs(dashboardJson?: {
__elements?: Record<string, unknown>;
}): Promise<LibraryPanelInput[]> {
if (!dashboardJson || !dashboardJson.__elements) {
return [];
}
const libraryPanelInputs: LibraryPanelInput[] = [];
for (const element of Object.values(dashboardJson.__elements)) {
if (!isLibraryElementExport(element)) {
continue;
}
if (element.kind !== LibraryElementKind.Panel) {
continue;
}
const model = element.model;
const { type, description } = model;
const { uid, name } = element;
const input: LibraryPanelInput = {
model: {
model,
uid,
name,
version: 0,
type,
description,
},
state: LibraryPanelInputState.New,
};
try {
const panelInDb = await getLibraryPanel(uid, true);
input.state = LibraryPanelInputState.Exists;
input.model = panelInDb;
} catch (e: unknown) {
if (typeof e === 'object' && e !== null && 'status' in e && e.status !== 404) {
throw e;
}
}
libraryPanelInputs.push(input);
}
return libraryPanelInputs;
}
/**
* Extract inputs from a v1/classic dashboard JSON
*/
export async function extractV1Inputs(dashboard: unknown): Promise<DashboardInputs> {
const inputs: DashboardInputs = {
dataSources: [],
constants: [],
libraryPanels: [],
};
if (!isRecord(dashboard)) {
return inputs;
}
const dashboardInputs = dashboard.__inputs;
if (Array.isArray(dashboardInputs)) {
for (const input of dashboardInputs) {
if (!isRecord(input)) {
continue;
}
const inputModel = {
name: String(input.name ?? ''),
label: String(input.label ?? ''),
info: String(input.description ?? ''),
value: String(input.value ?? ''),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
type: input.type as InputType,
...(input.type === InputType.DataSource ? { pluginId: String(input.pluginId ?? '') } : {}),
};
if (input.type === InputType.DataSource) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(inputModel as DataSourceInput).description = getDataSourceDescription(input);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
inputs.dataSources.push(inputModel as DataSourceInput);
} else if (input.type === InputType.Constant) {
if (!inputModel.info) {
inputModel.info = 'Specify a string constant';
}
inputs.constants.push(inputModel);
}
}
}
const elements = isRecord(dashboard) && isRecord(dashboard.__elements) ? dashboard.__elements : undefined;
inputs.libraryPanels = await getLibraryPanelInputs(elements ? { __elements: elements } : undefined);
return inputs;
}
/**
* Extract inputs from a v2 dashboard spec
*/
export function extractV2Inputs(dashboard: unknown): DashboardInputs {
const inputs: DashboardInputs = {
dataSources: [],
constants: [],
libraryPanels: [],
};
if (!isDashboardV2Spec(dashboard)) {
return inputs;
}
const dsTypes = new Set<string>();
if (dashboard.variables) {
for (const variable of dashboard.variables) {
if (variable.kind === 'QueryVariable') {
const dsType = variable.spec.query?.group;
if (dsType) {
dsTypes.add(dsType);
}
}
}
}
if (dashboard.annotations) {
for (const annotation of dashboard.annotations) {
// Skip built-in annotations
if (annotation.spec.builtIn) {
continue;
}
const dsType = annotation.spec.query?.group;
if (dsType) {
dsTypes.add(dsType);
}
}
}
if (dashboard.elements) {
for (const element of Object.values(dashboard.elements)) {
if (element.kind === 'Panel' && element.spec.data?.kind === 'QueryGroup') {
for (const query of element.spec.data.spec.queries) {
if (query.kind === 'PanelQuery') {
const dsType = query.spec.query?.group;
if (dsType) {
dsTypes.add(dsType);
}
}
}
}
}
}
for (const dsType of dsTypes) {
const dsInfo = getDataSourceSrv().getList({ pluginId: dsType });
inputs.dataSources.push({
name: dsType,
label: dsType,
info: dsInfo.length > 0 ? `Select a ${dsType} data source` : `No ${dsType} data sources found`,
description: `${dsType} data source`,
value: '',
type: InputType.DataSource,
pluginId: dsType,
});
}
return inputs;
}
function getDataSourceDescription(input: Record<string, unknown>): string {
const pluginId = String(input.pluginId ?? '');
const dsInfo = getDataSourceSrv().getList({ pluginId });
if (dsInfo.length === 0) {
return `No data sources of type ${pluginId} found`;
}
return `Select a ${pluginId} data source`;
}
/**
* Apply user's datasource selections to a v1 dashboard
*/
export function applyV1Inputs(
dashboard: Dashboard,
inputs: { dataSources: DataSourceInput[] },
form: ImportDashboardDTO
): Dashboard {
const annotations = (dashboard.annotations?.list ?? []).map((annotation: AnnotationQuery) => {
return processAnnotation(annotation, inputs, form);
});
const panels = (dashboard.panels ?? []).map((panel: Panel) => {
return processPanel(panel, inputs, form);
});
const variables = (dashboard.templating?.list ?? []).map((variable: VariableModel) => {
return processVariable(variable, inputs, form);
});
return {
...dashboard,
title: form.title,
...(dashboard.annotations ? { annotations: { ...dashboard.annotations, list: annotations } } : {}),
...(dashboard.panels ? { panels } : {}),
templating: {
...(dashboard.templating ?? {}),
list: variables,
},
uid: form.uid,
};
}
/**
* Apply user's datasource selections to a v2 dashboard.
* Builds mappings from the form and delegates to replaceDatasourcesInDashboard.
*/
export function applyV2Inputs(dashboard: DashboardV2Spec, form: ImportFormDataV2): DashboardV2Spec {
const mappings: DatasourceMappings = {};
for (const key of Object.keys(form)) {
if (key.startsWith('datasource-')) {
const dsType = key.replace('datasource-', '');
const ds = form[key];
if (isRecord(ds) && typeof ds.uid === 'string' && typeof ds.type === 'string') {
const name = typeof ds.name === 'string' ? ds.name : undefined;
mappings[dsType] = { uid: ds.uid, type: ds.type, name };
}
}
}
return replaceDatasourcesInDashboard(dashboard, mappings);
}
export function isVariableRef(dsName: string | undefined): boolean {
return dsName?.startsWith('$') ?? false;
}
export function replaceDatasourcesInDashboard(
dashboard: DashboardV2Spec,
mappings: DatasourceMappings
): DashboardV2Spec {
return {
...dashboard,
annotations: replaceAnnotationDatasources(dashboard.annotations, mappings),
variables: replaceVariableDatasources(dashboard.variables, mappings),
elements: replaceElementDatasources(dashboard.elements, mappings),
};
}
function replaceAnnotationDatasources(
annotations: DashboardV2Spec['annotations'],
mappings: DatasourceMappings
): DashboardV2Spec['annotations'] {
return annotations?.map((annotation: AnnotationQueryKind) => {
const dsType = annotation.spec.query?.group;
const currentDsName = annotation.spec.query?.datasource?.name;
const ds = dsType ? mappings[dsType] : undefined;
if (isVariableRef(currentDsName) || !dsType || !ds) {
return annotation;
}
return {
...annotation,
spec: {
...annotation.spec,
query: {
...annotation.spec.query,
datasource: { name: ds.uid },
},
},
};
});
}
function replaceVariableDatasources(
variables: DashboardV2Spec['variables'],
mappings: DatasourceMappings
): DashboardV2Spec['variables'] {
return variables?.map((variable) => {
if (variable.kind === 'QueryVariable') {
const dsType = variable.spec.query?.group;
const currentDsName = variable.spec.query?.datasource?.name;
const ds = dsType ? mappings[dsType] : undefined;
if (isVariableRef(currentDsName) || !dsType || !ds) {
return variable;
}
return {
...variable,
spec: {
...variable.spec,
query: {
...variable.spec.query,
datasource: { name: ds.uid },
},
options: [],
current: { text: '', value: '' },
refresh: 'onDashboardLoad' as const,
},
};
}
if (variable.kind === 'DatasourceVariable') {
const dsType = variable.spec.pluginId;
const ds = dsType ? mappings[dsType] : undefined;
if (!dsType || !ds) {
return variable;
}
return {
...variable,
spec: {
...variable.spec,
current: {
text: ds.name ?? ds.uid,
value: ds.uid,
},
},
};
}
if (variable.kind === 'AdhocVariable' || variable.kind === 'GroupByVariable') {
const dsType = variable.group;
const currentDsName = variable.datasource?.name;
const ds = dsType ? mappings[dsType] : undefined;
if (isVariableRef(currentDsName) || !dsType || !ds) {
return variable;
}
return {
...variable,
datasource: { name: ds.uid },
};
}
return variable;
});
}
function replaceElementDatasources(
elements: DashboardV2Spec['elements'],
mappings: DatasourceMappings
): DashboardV2Spec['elements'] {
return Object.fromEntries(
Object.entries(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) => {
if (query.kind !== 'PanelQuery') {
return query;
}
const queryType = query.spec.query?.group;
const currentDsName = query.spec.query?.datasource?.name;
const ds = queryType ? mappings[queryType] : undefined;
if (isVariableRef(currentDsName) || !queryType || !ds) {
return query;
}
return {
...query,
spec: {
...query.spec,
query: {
...query.spec.query,
datasource: { name: ds.uid },
},
},
};
});
panel.data = {
...panel.data,
spec: {
...panel.data.spec,
queries: newQueries,
},
};
}
return [key, { kind: element.kind, spec: panel }];
}
return [key, element];
})
);
}
function checkUserInputMatch(
templateizedUid: string,
datasourceInputs: DataSourceInput[],
userDsInputs: DataSourceInstanceSettings[]
) {
const dsName = templateizedUid.replace(/\$\{(.*)\}/, '$1');
const input = datasourceInputs?.find((ds) => ds.name === dsName);
const userInput = input && userDsInputs.find((ds) => ds.type === input.pluginId);
return userInput;
}
function processAnnotation(
annotation: AnnotationQuery,
inputs: { dataSources: DataSourceInput[] },
form: ImportDashboardDTO
): AnnotationQuery {
if (annotation.datasource && annotation.datasource.uid && annotation.datasource.uid.startsWith('$')) {
const userInput = checkUserInputMatch(annotation.datasource.uid, inputs.dataSources, form.dataSources);
if (userInput) {
return {
...annotation,
datasource: {
...annotation.datasource,
uid: userInput.uid,
},
};
}
}
return annotation;
}
function processPanel(panel: Panel, inputs: { dataSources: DataSourceInput[] }, form: ImportDashboardDTO): Panel {
if (panel.datasource && panel.datasource.uid && panel.datasource.uid.startsWith('$')) {
const userInput = checkUserInputMatch(panel.datasource.uid, inputs.dataSources, form.dataSources);
const queries = panel.targets?.map((target) => {
if (target.datasource && hasUid(target.datasource) && target.datasource.uid.startsWith('$')) {
const userInput = checkUserInputMatch(target.datasource.uid, inputs.dataSources, form.dataSources);
if (userInput) {
return {
...target,
datasource: {
...target.datasource,
uid: userInput.uid,
},
};
}
}
return target;
});
if (userInput) {
return {
...panel,
targets: queries,
datasource: {
...panel.datasource,
uid: userInput.uid,
},
};
}
}
return panel;
}
function processVariable(
variable: VariableModel,
inputs: { dataSources: DataSourceInput[] },
form: ImportDashboardDTO
) {
const variableType = variable.type;
if (variableType === 'query' && 'datasource' in variable && isRecord(variable.datasource)) {
const datasourceUid = variable.datasource.uid;
if (typeof datasourceUid === 'string' && datasourceUid.startsWith('$')) {
const userInput = checkUserInputMatch(datasourceUid, inputs.dataSources, form.dataSources);
if (userInput) {
return {
...variable,
datasource: {
...variable.datasource,
uid: userInput.uid,
},
};
}
}
}
if (variableType === 'datasource' && 'current' in variable && isRecord(variable.current)) {
const currentValue = variable.current.value;
if (currentValue && String(currentValue).startsWith('$')) {
const userInput = checkUserInputMatch(String(currentValue), inputs.dataSources, form.dataSources);
if (userInput) {
const selected = typeof variable.current.selected === 'boolean' ? variable.current.selected : undefined;
return {
...variable,
current: {
selected,
text: userInput.name,
value: userInput.uid,
},
};
}
}
}
return variable;
}

View file

@ -3,7 +3,7 @@ import { AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { validationSrv } from '../services/ValidationSrv';
import { validationSrv } from '../../services/ValidationSrv';
export const validateDashboardJson = (json: string) => {
let dashboard;

View file

@ -1,6 +1,13 @@
import { Dashboard } from '@grafana/schema/src/veneer/dashboard.types';
import { DataSourceInstanceSettings } from '@grafana/data';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { ExternalDashboard } from '../dashboard/components/DashExportModal/DashboardExporter';
import { LibraryElementDTO } from '../library-panels/types';
// Dashboard JSON type for import
export type DashboardJson = ExternalDashboard & Omit<Dashboard, 'panels'>;
export type DeleteDashboardResponse = {
id: number;
@ -28,4 +35,58 @@ export interface PublicDashboardListWithPagination extends PublicDashboardListWi
totalPages: number;
}
export type DashboardJson = ExternalDashboard & Omit<Dashboard, 'panels'>;
// Import-related types
export enum DashboardSource {
Gcom = 0,
Json = 1,
}
export interface ImportDashboardDTO {
title: string;
uid: string;
gnetId: string;
constants: string[];
dataSources: DataSourceInstanceSettings[];
elements: LibraryElementDTO[];
folder: { uid: string; title?: string };
}
export enum InputType {
DataSource = 'datasource',
Constant = 'constant',
LibraryPanel = 'libraryPanel',
}
export enum LibraryPanelInputState {
New = 'new',
Exists = 'exists',
Different = 'different',
}
export interface DashboardInput {
name: string;
label: string;
description?: string;
info: string;
value: string;
type: InputType;
}
export interface DataSourceInput extends DashboardInput {
pluginId: string;
}
export interface LibraryPanelInput {
model: LibraryElementDTO;
state: LibraryPanelInputState;
}
export interface DashboardInputs {
dataSources: DataSourceInput[];
constants: DashboardInput[];
libraryPanels: LibraryPanelInput[];
}
export type DatasourceSelection = { uid: string; type: string; name?: string };
export type ImportFormDataV2 = SaveDashboardCommand<DashboardV2Spec> & Record<string, unknown>;

View file

@ -19,6 +19,7 @@ import { reportInteraction, config } from '@grafana/runtime';
import { getAppPluginMetas } from '@grafana/runtime/internal';
import { Modal } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { isRecord } from 'app/core/utils/isRecord';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import {
CloseExtensionSidebarEvent,
@ -381,10 +382,6 @@ export function writableProxy<T>(value: T, options?: ProxyOptions): T {
return getMutationObserverProxy(cloneDeep(value), { log, pluginId, pluginVersion, source });
}
function isRecord(value: unknown): value is Record<string | number | symbol, unknown> {
return typeof value === 'object' && value !== null;
}
export function isReadOnlyProxy(value: unknown): boolean {
return isRecord(value) && value[_isReadOnlyProxy] === true;
}

View file

@ -5812,7 +5812,7 @@
"load": "Load"
},
"gcom-field": {
"label": "Find and import dashboards for common applications at <1></1>",
"label": "Find and import dashboards for common applications at <link />",
"load-button": "Load",
"placeholder": "Grafana.com dashboard URL or ID",
"validation-required": "A Grafana dashboard URL or ID is required"
@ -10358,10 +10358,16 @@
"options": "Options"
},
"import-dashboard-overview-un-connected": {
"importing-from": "Importing dashboard from <2>Grafana.com</2>",
"importing-from": "Importing dashboard from <1>Grafana.com</1>",
"published-by": "Published by",
"updated-on": "Updated on"
},
"import-resource-format-error": {
"cancel": "Cancel",
"title": "Unsupported format",
"v1-message": "This dashboard is in Kubernetes v1 resource format and cannot be imported when Kubernetes dashboards feature is disabled. Please enable the kubernetesDashboards feature toggle to import this dashboard.",
"v2-message": "This dashboard is in v2 resource format and cannot be imported when Kubernetes dashboards feature is disabled. Please enable the kubernetesDashboards feature toggle to import this dashboard."
},
"snapshot-list-table": {
"body-delete": "Are you sure you want to delete '{{snapshotToRemove}}'?",
"confirmText-delete": "Delete",