mirror of
https://github.com/grafana/grafana.git
synced 2026-02-18 18:20:52 -05:00
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:
parent
d8e5e03b7d
commit
381cc6555d
57 changed files with 3109 additions and 1636 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"kind": "DashboardWithAccessInfo",
|
||||
"metadata": {
|
||||
"name": "adtbh3z",
|
||||
"namespace": "default",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"kind": "DashboardWithAccessInfo",
|
||||
"metadata": {
|
||||
"name": "adtbg2z",
|
||||
"namespace": "default",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"kind": "DashboardWithAccessInfo",
|
||||
"metadata": {
|
||||
"name": "addfpww",
|
||||
"namespace": "default",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"kind": "DashboardWithAccessInfo",
|
||||
"metadata": {
|
||||
"name": "adbb8vn",
|
||||
"namespace": "default",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"kind": "DashboardWithAccessInfo",
|
||||
"metadata": {
|
||||
"name": "addwm76",
|
||||
"namespace": "default",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"kind": "DashboardWithAccessInfo",
|
||||
"metadata": {
|
||||
"name": "fa400625-2a44-4add-a369-e6c972eb4bd6",
|
||||
"generation": 1,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
3
public/app/core/utils/isRecord.ts
Normal file
3
public/app/core/utils/isRecord.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isRecord(value: unknown): value is Record<string | number | symbol, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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());
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
560
public/app/features/manage-dashboards/import/utils/inputs.ts
Normal file
560
public/app/features/manage-dashboards/import/utils/inputs.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue