mirror of https://github.com/grafana/grafana.git
Dashboard: Redirect between `v1alpha1` and `v2alpha1` depending on stored version (#101292)
* wip: Create a proxy state manager to avoid complexity * Read path redirecting * add tests for unified dashboard API * add tests * Contemplate both formats in DashboardProxy * Fix force old * Fix tests for proxy * catch errors * Save as V2 when dynamic dashboard is enabled * Improve tests * Remove feature toggle * Use kubernetesDashboards for e2e suite * Fix issue when loading snapshots * Fix typescript errors * Integrate with backend conversion error * Remove legacy annotation * fix snapshot loading; lint * Add missing hideTimeControls * fix test * make setupDashboardAPI to all suites * refactor getDashboardAPI * Add tests * fix DashboardScenePage tests * fix tests * fix go tests * Refactor to understand better the need of transforming to v2 to compare * Fix detect changes logic * yes status from schema gen --------- Co-authored-by: alexandra vargas <alexa1866@gmail.com> Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
This commit is contained in:
parent
0233c39a7f
commit
bfedf0b512
|
|
@ -3143,12 +3143,14 @@ exports[`better eslint`] = {
|
|||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "9"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "10"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "11"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "12"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "15"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "16"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "17"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "17"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "18"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "19"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/saving/SaveProvisionedDashboardForm.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
|
|
@ -3166,7 +3168,8 @@ exports[`better eslint`] = {
|
|||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/saving/shared.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
|
|
@ -3714,8 +3717,9 @@ exports[`better eslint`] = {
|
|||
],
|
||||
"public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
|
|
|
|||
|
|
@ -214,7 +214,6 @@ Experimental features might be changed or removed without prior notice.
|
|||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
| `crashDetection` | Enables browser crash detection reporting to Faro. |
|
||||
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy |
|
||||
| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards |
|
||||
| `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
|
||||
| `investigationsBackend` | Enable the investigations backend API |
|
||||
| `k8SFolderCounts` | Enable folder's api server counts |
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ beforeEach(() => {
|
|||
cy.setLocalStorage('grafana.featureToggles', 'dashboardScene=false');
|
||||
}
|
||||
|
||||
if (Cypress.env('useV2DashboardsAPI')) {
|
||||
cy.logToConsole('enabling v2 dashboards API in localstorage');
|
||||
cy.setLocalStorage('grafana.featureToggles', 'useV2DashboardsAPI=true');
|
||||
if (Cypress.env('kubernetesDashboards')) {
|
||||
cy.logToConsole('enabling kubernetes dashboards API in localstorage');
|
||||
cy.setLocalStorage('grafana.featureToggles', 'kubernetesDashboards=true');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ declare -A env=(
|
|||
testFilesForSingleSuite="*.spec.ts"
|
||||
rootForEnterpriseSuite="./e2e/extensions-suite"
|
||||
rootForOldArch="./e2e/old-arch"
|
||||
rootForDashboardsSchemaV2="./e2e/dashboards-suite"
|
||||
rootForKubernetesDashboards="./e2e/dashboards-suite"
|
||||
|
||||
declare -A cypressConfig=(
|
||||
[screenshotsFolder]=./e2e/"${args[0]}"/screenshots
|
||||
|
|
@ -113,8 +113,8 @@ case "$1" in
|
|||
env[DISABLE_SCENES]=true
|
||||
;;
|
||||
"dashboards-schema-v2")
|
||||
env[useV2DashboardsAPI]=true
|
||||
cypressConfig[specPattern]=$rootForDashboardsSchemaV2/$testFilesForSingleSuite
|
||||
env[kubernetesDashboards]=true
|
||||
cypressConfig[specPattern]=$rootForKubernetesDashboards/$testFilesForSingleSuite
|
||||
cypressConfig[video]=false
|
||||
case "$2" in
|
||||
"debug")
|
||||
|
|
|
|||
|
|
@ -225,7 +225,6 @@ export interface FeatureToggles {
|
|||
alertingUIOptimizeReducer?: boolean;
|
||||
azureMonitorEnableUserAuth?: boolean;
|
||||
alertingNotificationsStepMode?: boolean;
|
||||
useV2DashboardsAPI?: boolean;
|
||||
feedbackButton?: boolean;
|
||||
unifiedStorageSearchUI?: boolean;
|
||||
elasticsearchCrossClusterSearch?: boolean;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ import (
|
|||
"github.com/grafana/grafana-app-sdk/app"
|
||||
)
|
||||
|
||||
var ()
|
||||
|
||||
var appManifestData = app.ManifestData{
|
||||
AppName: "dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ func RegisterAPIService(
|
|||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagUseV2DashboardsAPI) {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
|
||||
// If dashboards v2 is enabled, we want to use v2alpha1 as the default API version.
|
||||
return []schema.GroupVersion{
|
||||
dashboardv2alpha1.DashboardResourceInfo.GroupVersion(),
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ func TestDashboardAPIBuilder_GetGroupVersions(t *testing.T) {
|
|||
{
|
||||
name: "should return v2alpha1 as the default if dashboards v2 is enabled",
|
||||
enabledFeatures: []string{
|
||||
featuremgmt.FlagUseV2DashboardsAPI,
|
||||
featuremgmt.FlagDashboardNewLayouts,
|
||||
},
|
||||
expected: []schema.GroupVersion{
|
||||
v2alpha1.DashboardResourceInfo.GroupVersion(),
|
||||
|
|
|
|||
|
|
@ -1559,13 +1559,6 @@ var (
|
|||
FrontendOnly: true,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "useV2DashboardsAPI",
|
||||
Description: "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
RequiresRestart: true, // changes the API routing
|
||||
},
|
||||
{
|
||||
Name: "feedbackButton",
|
||||
Description: "Enables a button to send feedback from the Grafana UI",
|
||||
|
|
|
|||
|
|
@ -206,7 +206,6 @@ reportingUseRawTimeRange,GA,@grafana/sharing-squad,false,false,false
|
|||
alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
|
||||
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
|
||||
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false
|
||||
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
|
||||
elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false
|
||||
|
|
|
|||
|
|
|
@ -835,10 +835,6 @@ const (
|
|||
// Enables simplified step mode in the notifications section
|
||||
FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode"
|
||||
|
||||
// FlagUseV2DashboardsAPI
|
||||
// Use the v2 kubernetes API in the frontend for dashboards
|
||||
FlagUseV2DashboardsAPI = "useV2DashboardsAPI"
|
||||
|
||||
// FlagFeedbackButton
|
||||
// Enables a button to send feedback from the Grafana UI
|
||||
FlagFeedbackButton = "feedbackButton"
|
||||
|
|
|
|||
|
|
@ -4259,7 +4259,8 @@
|
|||
"metadata": {
|
||||
"name": "useV2DashboardsAPI",
|
||||
"resourceVersion": "1732535420861",
|
||||
"creationTimestamp": "2024-12-17T21:17:09Z"
|
||||
"creationTimestamp": "2024-12-17T21:17:09Z",
|
||||
"deletionTimestamp": "2025-03-04T10:50:39Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { noop } from 'lodash';
|
|||
import { Props } from 'react-virtualized-auto-sizer';
|
||||
import { render, screen, userEvent, waitFor } from 'test/test-utils';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { defaultDashboard as defaultDashboardData } from '@grafana/schema';
|
||||
import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
|
@ -104,7 +103,6 @@ describe('DashboardPicker', () => {
|
|||
['v2', mockDashboardV2],
|
||||
])('Dashboard %s', (format, dashboard) => {
|
||||
beforeEach(() => {
|
||||
config.featureToggles.useV2DashboardsAPI = format === 'v2';
|
||||
getDashboardDTO.mockResolvedValue(dashboard);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import debounce from 'debounce-promise';
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
|
||||
import { DashboardSearchItem } from 'app/features/search/types';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
|
||||
|
|
@ -57,34 +57,28 @@ export const DashboardPicker = ({
|
|||
(async () => {
|
||||
// value was manually changed from outside or we are rendering for the first time.
|
||||
// We need to fetch dashboard information.
|
||||
const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI;
|
||||
if (isUIReadyForV2) {
|
||||
// When using getDashboardAPI, if isUIReadyForV2 is true, we will pass `v2` prop
|
||||
// That will return a dashboard response using schema v2. We only ask for `v2` when the component is ready to process the new shape
|
||||
const resWithSchemaV2 = await getDashboardAPI('v2').getDashboardDTO(value, undefined);
|
||||
const dto = await getDashboardAPI().getDashboardDTO(value, undefined);
|
||||
|
||||
if (isDashboardV2Resource(dto)) {
|
||||
setCurrent({
|
||||
value: {
|
||||
uid: resWithSchemaV2.metadata.name,
|
||||
title: resWithSchemaV2.spec.title,
|
||||
folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle],
|
||||
folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder],
|
||||
uid: dto.metadata.name,
|
||||
title: dto.spec.title,
|
||||
folderTitle: dto.metadata.annotations?.[AnnoKeyFolderTitle],
|
||||
folderUid: dto.metadata.annotations?.[AnnoKeyFolder],
|
||||
},
|
||||
label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title),
|
||||
label: formatLabel(dto.metadata.annotations?.[AnnoKeyFolder], dto.spec.title),
|
||||
});
|
||||
} else {
|
||||
// when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version
|
||||
const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined);
|
||||
|
||||
if (resWithSchemaV1.dashboard) {
|
||||
if (dto.dashboard) {
|
||||
setCurrent({
|
||||
value: {
|
||||
uid: resWithSchemaV1.dashboard.uid,
|
||||
title: resWithSchemaV1.dashboard.title,
|
||||
folderTitle: resWithSchemaV1.meta.folderTitle,
|
||||
folderUid: resWithSchemaV1.meta.folderUid,
|
||||
uid: dto.dashboard.uid,
|
||||
title: dto.dashboard.title,
|
||||
folderTitle: dto.meta.folderTitle,
|
||||
folderUid: dto.meta.folderUid,
|
||||
},
|
||||
label: formatLabel(resWithSchemaV1.meta?.folderTitle, resWithSchemaV1.dashboard.title),
|
||||
label: formatLabel(dto.meta?.folderTitle, dto.dashboard.title),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -602,7 +602,7 @@ export class BackendSrv implements BackendService {
|
|||
// NOTE: When this is removed, we can also remove most instances of:
|
||||
// jest.mock('app/features/live/dashboard/dashboardWatcher
|
||||
deprecationWarning('backend_srv', 'getDashboardByUid(uid)', 'getDashboardAPI().getDashboardDTO(uid)');
|
||||
return getDashboardAPI().getDashboardDTO(uid);
|
||||
return getDashboardAPI('v1').getDashboardDTO(uid);
|
||||
}
|
||||
|
||||
validateDashboard(dashboard: DashboardModel): Promise<ValidateDashboardResponse> {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery';
|
|||
import appEvents from 'app/core/app_events';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils';
|
||||
import { isDashboardV2Resource, isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils';
|
||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import {
|
||||
DashboardDTO,
|
||||
DescendantCount,
|
||||
DescendantCountDTO,
|
||||
FolderDTO,
|
||||
|
|
@ -240,26 +239,16 @@ export const browseDashboardsAPI = createApi({
|
|||
// Move all the dashboards sequentially
|
||||
// TODO error handling here
|
||||
for (const dashboardUID of selectedDashboards) {
|
||||
if (config.featureToggles.useV2DashboardsAPI) {
|
||||
const fullDash = await getDashboardAPI('v2').getDashboardDTO(dashboardUID);
|
||||
|
||||
await getDashboardAPI('v2').saveDashboard({
|
||||
dashboard: fullDash.spec,
|
||||
folderUid: destinationUID,
|
||||
overwrite: false,
|
||||
message: '',
|
||||
k8s: fullDash.metadata,
|
||||
});
|
||||
} else {
|
||||
const fullDash: DashboardDTO = await getDashboardAPI().getDashboardDTO(dashboardUID);
|
||||
|
||||
await getDashboardAPI().saveDashboard({
|
||||
dashboard: fullDash.dashboard,
|
||||
folderUid: destinationUID,
|
||||
overwrite: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
const fullDash = await getDashboardAPI().getDashboardDTO(dashboardUID);
|
||||
const dashboard = isDashboardV2Resource(fullDash) ? fullDash.spec : fullDash.dashboard;
|
||||
const k8s = isDashboardV2Resource(fullDash) ? fullDash.metadata : undefined;
|
||||
await getDashboardAPI().saveDashboard({
|
||||
dashboard,
|
||||
folderUid: destinationUID,
|
||||
overwrite: false,
|
||||
message: '',
|
||||
k8s,
|
||||
});
|
||||
}
|
||||
return { data: undefined };
|
||||
},
|
||||
|
|
@ -308,21 +297,20 @@ export const browseDashboardsAPI = createApi({
|
|||
const name = response?.title;
|
||||
|
||||
if (name) {
|
||||
const payload =
|
||||
config.featureToggles.useV2DashboardsAPI || config.featureToggles.kubernetesDashboards
|
||||
? ['Dashboard moved to Recently deleted']
|
||||
: [
|
||||
t('browse-dashboards.soft-delete.success', 'Dashboard {{name}} moved to Recently deleted', {
|
||||
name,
|
||||
}),
|
||||
];
|
||||
const payload = config.featureToggles.kubernetesDashboards
|
||||
? ['Dashboard moved to Recently deleted']
|
||||
: [
|
||||
t('browse-dashboards.soft-delete.success', 'Dashboard {{name}} moved to Recently deleted', {
|
||||
name,
|
||||
}),
|
||||
];
|
||||
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
} else if (config.featureToggles.useV2DashboardsAPI || config.featureToggles.kubernetesDashboards) {
|
||||
} else if (config.featureToggles.kubernetesDashboards) {
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: ['Dashboard deleted'],
|
||||
|
|
@ -344,8 +332,7 @@ export const browseDashboardsAPI = createApi({
|
|||
saveDashboard: builder.mutation<SaveDashboardResponseDTO, SaveDashboardCommand<Dashboard | DashboardV2Spec>>({
|
||||
queryFn: async (cmd) => {
|
||||
try {
|
||||
// When we use the `useV2DashboardsAPI` flag, we can save 'v2' schema dashboards
|
||||
if (config.featureToggles.useV2DashboardsAPI && isV2DashboardCommand(cmd)) {
|
||||
if (isV2DashboardCommand(cmd)) {
|
||||
const response = await getDashboardAPI('v2').saveDashboard(cmd);
|
||||
return { data: response };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useParams } from 'react-router-dom-v5-compat';
|
|||
import { usePrevious } from 'react-use';
|
||||
|
||||
import { PageLayoutType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { UrlSyncContextProvider } from '@grafana/scenes';
|
||||
import { Box } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
|
@ -25,9 +24,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
|
|||
const params = useParams();
|
||||
const { type, slug, uid } = params;
|
||||
const prevMatch = usePrevious({ params });
|
||||
const stateManager = config.featureToggles.useV2DashboardsAPI
|
||||
? getDashboardScenePageStateManager('v2')
|
||||
: getDashboardScenePageStateManager();
|
||||
const stateManager = getDashboardScenePageStateManager();
|
||||
const { dashboard, isLoading, loadError } = stateManager.useState();
|
||||
// After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need
|
||||
const routeReloadCounter = (location.state as any)?.routeReloadCounter;
|
||||
|
|
|
|||
|
|
@ -4,15 +4,16 @@ import { BackendSrv, setBackendSrv } from '@grafana/runtime';
|
|||
import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import store from 'app/core/store';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { getDashboardSnapshotSrv } from 'app/features/dashboard/services/SnapshotSrv';
|
||||
import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types';
|
||||
import { DASHBOARD_FROM_LS_KEY, DashboardDataDTO, DashboardDTO, DashboardRoutes } from 'app/types';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { setupLoadDashboardMock, setupLoadDashboardMockReject } from '../utils/test-utils';
|
||||
|
||||
import {
|
||||
DashboardScenePageStateManager,
|
||||
UnifiedDashboardScenePageStateManager,
|
||||
DASHBOARD_CACHE_TTL,
|
||||
DashboardScenePageStateManagerV2,
|
||||
} from './DashboardScenePageStateManager';
|
||||
|
|
@ -21,6 +22,22 @@ jest.mock('app/features/dashboard/api/dashboard_api', () => ({
|
|||
getDashboardAPI: jest.fn(),
|
||||
}));
|
||||
|
||||
const setupDashboardAPI = (
|
||||
d: DashboardWithAccessInfo<DashboardV2Spec> | undefined,
|
||||
spy: jest.Mock,
|
||||
effect?: () => void
|
||||
) => {
|
||||
(getDashboardAPI as jest.Mock).mockImplementation(() => ({
|
||||
getDashboardDTO: async () => {
|
||||
spy();
|
||||
effect?.();
|
||||
return d;
|
||||
},
|
||||
deleteDashboard: jest.fn(),
|
||||
saveDashboard: jest.fn(),
|
||||
}));
|
||||
};
|
||||
|
||||
describe('DashboardScenePageStateManager v1', () => {
|
||||
afterEach(() => {
|
||||
store.delete(DASHBOARD_FROM_LS_KEY);
|
||||
|
|
@ -165,7 +182,7 @@ describe('DashboardScenePageStateManager v1', () => {
|
|||
|
||||
expect(loader.state.dashboard).toBeUndefined();
|
||||
expect(loader.state.loadError).toEqual({
|
||||
message: 'v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle',
|
||||
message: 'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.',
|
||||
messageId: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
|
@ -267,24 +284,6 @@ describe('DashboardScenePageStateManager v2', () => {
|
|||
});
|
||||
|
||||
describe('when fetching/loading a dashboard', () => {
|
||||
const setupDashboardAPI = (
|
||||
d: DashboardWithAccessInfo<DashboardV2Spec> | undefined,
|
||||
spy: jest.Mock,
|
||||
effect?: () => void
|
||||
) => {
|
||||
(getDashboardAPI as jest.Mock).mockImplementation(() => {
|
||||
// Return whatever you want for this mock
|
||||
return {
|
||||
getDashboardDTO: async () => {
|
||||
spy();
|
||||
effect?.();
|
||||
return d;
|
||||
},
|
||||
deleteDashboard: jest.fn(),
|
||||
saveDashboard: jest.fn(),
|
||||
};
|
||||
});
|
||||
};
|
||||
it('should call loader from server if the dashboard is not cached', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
|
|
@ -473,10 +472,9 @@ describe('DashboardScenePageStateManager v2', () => {
|
|||
|
||||
it('should not transform v2 custom home dashboard spec', async () => {
|
||||
setBackendSrv({
|
||||
get: () =>
|
||||
Promise.resolve({
|
||||
dashboard: customHomeDashboardV2Spec,
|
||||
meta: {
|
||||
get: () => {
|
||||
return Promise.resolve({
|
||||
access: {
|
||||
canSave: false,
|
||||
canEdit: true,
|
||||
canAdmin: false,
|
||||
|
|
@ -500,7 +498,16 @@ describe('DashboardScenePageStateManager v2', () => {
|
|||
provisionedExternalId: '',
|
||||
annotationsPermissions: null,
|
||||
},
|
||||
}),
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'home',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: customHomeDashboardV2Spec,
|
||||
});
|
||||
},
|
||||
} as unknown as BackendSrv);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
|
|
@ -648,6 +655,142 @@ describe('DashboardScenePageStateManager v2', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('UnifiedDashboardScenePageStateManager', () => {
|
||||
afterEach(() => {
|
||||
store.delete(DASHBOARD_FROM_LS_KEY);
|
||||
});
|
||||
|
||||
describe('when fetching/loading a dashboard', () => {
|
||||
it('should use v1 manager by default and handle v1 dashboards', async () => {
|
||||
const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} });
|
||||
|
||||
const manager = new UnifiedDashboardScenePageStateManager({});
|
||||
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash', undefined);
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager);
|
||||
});
|
||||
|
||||
it('should switch to v2 manager when loading v2 dashboard', async () => {
|
||||
setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1'));
|
||||
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const manager = new UnifiedDashboardScenePageStateManager({});
|
||||
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
|
||||
expect(getDashSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should maintain active manager state between operations', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1'));
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const manager = new UnifiedDashboardScenePageStateManager({});
|
||||
|
||||
// First load switches to v2
|
||||
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
|
||||
|
||||
// Cache should use the active v2 manager
|
||||
const cachedDash = manager.getDashboardFromCache('fake-dash');
|
||||
expect(cachedDash).toBeDefined();
|
||||
});
|
||||
|
||||
it.todo('should handle snapshot loading for both v1 and v2');
|
||||
|
||||
it('should handle dashboard reloading with current active manager', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
setupLoadDashboardMockReject(new DashboardVersionError('v2alpha1'));
|
||||
|
||||
const manager = new UnifiedDashboardScenePageStateManager({});
|
||||
|
||||
// Initial load with v2 dashboard
|
||||
await manager.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
|
||||
|
||||
// Reload for v2 is not supported yet
|
||||
await expect(
|
||||
manager.reloadDashboard({ version: 1, scopes: [], timeRange: { from: 'now-1h', to: 'now' }, variables: {} })
|
||||
).rejects.toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should transform responses correctly based on dashboard version', async () => {
|
||||
const manager = new UnifiedDashboardScenePageStateManager({});
|
||||
|
||||
// V1 dashboard response
|
||||
const v1Response: DashboardDTO = {
|
||||
dashboard: { uid: 'v1-dash', title: 'V1 Dashboard' } as DashboardDataDTO,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const v1Scene = manager.transformResponseToScene(v1Response, { uid: 'v1-dash', route: DashboardRoutes.Normal });
|
||||
expect(v1Scene).toBeInstanceOf(DashboardScene);
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager);
|
||||
|
||||
// V2 dashboard response
|
||||
const v2Response: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'v2-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
};
|
||||
|
||||
const v2Scene = manager.transformResponseToScene(v2Response, { uid: 'v2-dash', route: DashboardRoutes.Normal });
|
||||
expect(v2Scene).toBeInstanceOf(DashboardScene);
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const customHomeDashboardV2Spec = {
|
||||
title: 'Home Dashboard v2 schema',
|
||||
cursorSync: 'Off',
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import { StateManagerBase } from 'app/core/services/StateManagerBase';
|
|||
import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors';
|
||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||
import { AnnoKeyFolder } from 'app/features/apiserver/types';
|
||||
import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
|
||||
import { DashboardVersionError, DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { isDashboardV2Resource, isDashboardV2Spec } from 'app/features/dashboard/api/utils';
|
||||
import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
|
||||
|
|
@ -293,6 +292,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
|||
|
||||
break;
|
||||
case DashboardRoutes.Home:
|
||||
// TODO: Move this fetching to APIClient.getHomeDashboard() to be able to redirect to the correct api depending on the format for the saved dashboard
|
||||
rsp = await getBackendSrv().get<HomeDashboardDTO | HomeDashboardRedirectDTO>('/api/dashboards/home');
|
||||
|
||||
if (isRedirectResponse(rsp)) {
|
||||
|
|
@ -302,7 +302,9 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
|||
}
|
||||
|
||||
if (isDashboardV2Spec(rsp.dashboard)) {
|
||||
throw new Error('v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle');
|
||||
throw new Error(
|
||||
'You are trying to load a v2 dashboard spec as v1. Use DashboardScenePageStateManagerV2 instead.'
|
||||
);
|
||||
}
|
||||
|
||||
if (rsp?.meta) {
|
||||
|
|
@ -501,6 +503,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
|
|||
rsp = await buildNewDashboardSaveModelV2(urlFolderUid);
|
||||
break;
|
||||
case DashboardRoutes.Home:
|
||||
// TODO: Move this fetching to APIClient.getHomeDashboard() to be able to redirect to the correct api depending on the format for the saved dashboard
|
||||
const dto = await getBackendSrv().get<HomeDashboardDTO | HomeDashboardRedirectDTO>('/api/dashboards/home');
|
||||
|
||||
if (isRedirectResponse(dto)) {
|
||||
|
|
@ -509,16 +512,15 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
|
|||
return null;
|
||||
}
|
||||
|
||||
rsp = ResponseTransformers.ensureV2Response(dto);
|
||||
|
||||
// if custom home dashboard is v2 spec already, ignore the spec transformation
|
||||
if (isDashboardV2Spec(dto.dashboard)) {
|
||||
rsp.spec = dto.dashboard;
|
||||
if (!isDashboardV2Resource(dto)) {
|
||||
throw new Error('Custom home dashboard is not a v2 spec');
|
||||
}
|
||||
|
||||
rsp.access.canSave = false;
|
||||
rsp.access.canShare = false;
|
||||
rsp.access.canStar = false;
|
||||
rsp = dto;
|
||||
dto.access.canSave = false;
|
||||
dto.access.canShare = false;
|
||||
dto.access.canStar = false;
|
||||
|
||||
break;
|
||||
case DashboardRoutes.Public: {
|
||||
|
|
@ -569,32 +571,143 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
|
|||
}
|
||||
}
|
||||
|
||||
export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase<
|
||||
DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>
|
||||
> {
|
||||
private v1Manager: DashboardScenePageStateManager;
|
||||
private v2Manager: DashboardScenePageStateManagerV2;
|
||||
private activeManager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2;
|
||||
|
||||
constructor(initialState: Partial<DashboardScenePageState>) {
|
||||
super(initialState);
|
||||
this.v1Manager = new DashboardScenePageStateManager(initialState);
|
||||
this.v2Manager = new DashboardScenePageStateManagerV2(initialState);
|
||||
|
||||
// Start with v2 if newDashboardLayout is enabled, otherwise v1
|
||||
this.activeManager = this.v1Manager;
|
||||
}
|
||||
|
||||
private async withVersionHandling<T>(
|
||||
operation: (manager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2) => Promise<T>
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await operation(this.activeManager);
|
||||
// need to sync the state of the active manager with the unified manager
|
||||
// in cases when components are subscribed to unified manager's state
|
||||
this.setState(this.activeManager.state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardVersionError) {
|
||||
const manager = error.data.storedVersion === 'v2alpha1' ? this.v2Manager : this.v1Manager;
|
||||
this.activeManager = manager;
|
||||
return await operation(manager);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchDashboard(options: LoadDashboardOptions) {
|
||||
return this.withVersionHandling<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | null>((manager) =>
|
||||
manager.fetchDashboard(options)
|
||||
);
|
||||
}
|
||||
|
||||
public async reloadDashboard(params: LoadDashboardOptions['params']) {
|
||||
return this.withVersionHandling((manager) => manager.reloadDashboard(params));
|
||||
}
|
||||
|
||||
public getDashboardFromCache(uid: string) {
|
||||
return this.activeManager.getDashboardFromCache(uid);
|
||||
}
|
||||
|
||||
transformResponseToScene(
|
||||
rsp: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | null,
|
||||
options: LoadDashboardOptions
|
||||
): DashboardScene | null {
|
||||
if (!rsp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isDashboardV2Resource(rsp)) {
|
||||
this.activeManager = this.v2Manager;
|
||||
return this.v2Manager.transformResponseToScene(rsp, options);
|
||||
}
|
||||
|
||||
return this.v1Manager.transformResponseToScene(rsp, options);
|
||||
}
|
||||
|
||||
public async loadSnapshotScene(slug: string): Promise<DashboardScene> {
|
||||
try {
|
||||
return await this.v1Manager.loadSnapshotScene(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardVersionError && error.data.storedVersion === 'v2alpha1') {
|
||||
return await this.v2Manager.loadSnapshotScene(slug);
|
||||
}
|
||||
throw new Error('Snapshot not found');
|
||||
}
|
||||
}
|
||||
|
||||
public async loadSnapshot(slug: string) {
|
||||
return this.withVersionHandling((manager) => manager.loadSnapshot(slug));
|
||||
}
|
||||
|
||||
public clearDashboardCache() {
|
||||
this.v1Manager.clearDashboardCache();
|
||||
this.v2Manager.clearDashboardCache();
|
||||
}
|
||||
|
||||
public clearSceneCache() {
|
||||
this.v1Manager.clearSceneCache();
|
||||
this.v2Manager.clearSceneCache();
|
||||
this.cache = {};
|
||||
}
|
||||
|
||||
public getCache() {
|
||||
return this.activeManager.getCache();
|
||||
}
|
||||
|
||||
public setDashboardCache(cacheKey: string, dashboard: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>) {
|
||||
if (isDashboardV2Resource(dashboard)) {
|
||||
this.v2Manager.setDashboardCache(cacheKey, dashboard);
|
||||
} else {
|
||||
this.v1Manager.setDashboardCache(cacheKey, dashboard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const managers: {
|
||||
v1?: DashboardScenePageStateManager;
|
||||
v2?: DashboardScenePageStateManagerV2;
|
||||
unified?: UnifiedDashboardScenePageStateManager;
|
||||
} = {
|
||||
v1: undefined,
|
||||
v2: undefined,
|
||||
unified: undefined,
|
||||
};
|
||||
|
||||
export function getDashboardScenePageStateManager(
|
||||
v: 'v2'
|
||||
): DashboardScenePageStateManagerLike<DashboardWithAccessInfo<DashboardV2Spec>>;
|
||||
export function getDashboardScenePageStateManager(): DashboardScenePageStateManagerLike<DashboardDTO>;
|
||||
export function getDashboardScenePageStateManager(): UnifiedDashboardScenePageStateManager;
|
||||
export function getDashboardScenePageStateManager(v: 'v1'): DashboardScenePageStateManager;
|
||||
export function getDashboardScenePageStateManager(v: 'v2'): DashboardScenePageStateManagerV2;
|
||||
|
||||
export function getDashboardScenePageStateManager(
|
||||
v?: 'v2'
|
||||
): DashboardScenePageStateManagerLike<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>> {
|
||||
if (v === 'v2') {
|
||||
if (!managers.v2) {
|
||||
managers.v2 = new DashboardScenePageStateManagerV2({});
|
||||
}
|
||||
|
||||
return managers.v2;
|
||||
} else {
|
||||
export function getDashboardScenePageStateManager(v?: 'v1' | 'v2') {
|
||||
if (v === 'v1') {
|
||||
if (!managers.v1) {
|
||||
managers.v1 = new DashboardScenePageStateManager({});
|
||||
}
|
||||
return managers.v1;
|
||||
}
|
||||
|
||||
if (v === 'v2') {
|
||||
if (!managers.v2) {
|
||||
managers.v2 = new DashboardScenePageStateManagerV2({});
|
||||
}
|
||||
return managers.v2;
|
||||
}
|
||||
|
||||
if (!managers.unified) {
|
||||
managers.unified = new UnifiedDashboardScenePageStateManager({});
|
||||
}
|
||||
|
||||
return managers.unified;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
|||
.resolve()
|
||||
.getDashboardChanges(saveTimeRange, saveVariables, saveRefresh);
|
||||
|
||||
const { changedSaveModel, initialSaveModel, diffs, diffCount, hasFolderChanges } = changeInfo;
|
||||
const { changedSaveModel, initialSaveModel, diffs, diffCount, hasFolderChanges, hasMigratedToV2 } = changeInfo;
|
||||
const changesCount = diffCount + (hasFolderChanges ? 1 : 0);
|
||||
const dashboard = model.state.dashboardRef.resolve();
|
||||
const { meta } = dashboard.useState();
|
||||
|
|
@ -77,6 +77,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
|||
oldValue={initialSaveModel}
|
||||
newValue={changedSaveModel}
|
||||
hasFolderChanges={hasFolderChanges}
|
||||
hasMigratedToV2={hasMigratedToV2}
|
||||
oldFolder={dashboard.getInitialState()?.meta.folderTitle}
|
||||
newFolder={folderTitle}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export interface Props {
|
|||
}
|
||||
|
||||
export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
|
||||
const { hasChanges, changedSaveModel } = changeInfo;
|
||||
const { hasChanges, hasMigratedToV2, changedSaveModel } = changeInfo;
|
||||
|
||||
const { state, onSaveDashboard } = useSaveDashboard(false);
|
||||
const [options, setOptions] = useState<SaveDashboardOptions>({
|
||||
|
|
@ -128,6 +128,15 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
|
|||
return (
|
||||
<Stack gap={2} direction="column">
|
||||
<SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />
|
||||
{hasMigratedToV2 && (
|
||||
<Alert title="Dashboard drastically changed" severity="warning">
|
||||
<p>
|
||||
Because you're using new dashboards features only supported on new Grafana dashboard schema format, the
|
||||
dashboard will be saved in the new format. Please make sure you want to perform this action or you prefer to
|
||||
save the dashboard as a new copy.
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
<Field label="Message">
|
||||
<TextArea
|
||||
aria-label="message"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import {
|
|||
DashboardV2Spec,
|
||||
VariableKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers';
|
||||
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
|
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types';
|
||||
|
||||
import { jsonDiff } from '../settings/version-history/utils';
|
||||
|
||||
|
|
@ -36,13 +39,16 @@ export function isEqual(a: VariableOption | undefined, b: VariableOption | undef
|
|||
}
|
||||
|
||||
export function getRawDashboardV2Changes(
|
||||
initial: DashboardV2Spec,
|
||||
initial: DashboardV2Spec | Dashboard,
|
||||
changed: DashboardV2Spec,
|
||||
saveTimeRange?: boolean,
|
||||
saveVariables?: boolean,
|
||||
saveRefresh?: boolean
|
||||
) {
|
||||
const initialSaveModel = initial;
|
||||
// Transform initial dashboard values to v2 spec format to ensure consistent comparison of time settings,
|
||||
// variables and refresh values. This handles cases where the initial dashboard is in v1 format
|
||||
// but was converted to v2 during runtime due to dynamic dashboard features being used.
|
||||
const initialSaveModel = convertToV2SpecIfNeeded(initial);
|
||||
const changedSaveModel = changed;
|
||||
const hasTimeChanged = getHasTimeChanged(changedSaveModel.timeSettings, initialSaveModel.timeSettings);
|
||||
const hasVariableValueChanges = applyVariableChangesV2(changedSaveModel, initialSaveModel, saveVariables);
|
||||
|
|
@ -57,7 +63,8 @@ export function getRawDashboardV2Changes(
|
|||
changedSaveModel.timeSettings.autoRefresh = initialSaveModel.timeSettings.autoRefresh;
|
||||
}
|
||||
|
||||
const diff = jsonDiff(initialSaveModel, changedSaveModel);
|
||||
// Calculate differences using the non-transformed to v2 spec values to be able to compare the initial and changed dashboard values
|
||||
const diff = jsonDiff(initial, changedSaveModel);
|
||||
const diffCount = Object.values(diff).reduce((acc, cur) => acc + cur.length, 0);
|
||||
|
||||
return {
|
||||
|
|
@ -69,9 +76,22 @@ export function getRawDashboardV2Changes(
|
|||
hasTimeChanges: hasTimeChanged,
|
||||
hasVariableValueChanges,
|
||||
hasRefreshChange: hasRefreshChanged,
|
||||
hasMigratedToV2: !isDashboardV2Spec(initial),
|
||||
};
|
||||
}
|
||||
|
||||
function convertToV2SpecIfNeeded(initial: DashboardV2Spec | Dashboard): DashboardV2Spec {
|
||||
if (isDashboardV2Spec(initial)) {
|
||||
return initial;
|
||||
}
|
||||
|
||||
const dto: DashboardDTO = {
|
||||
dashboard: initial as DashboardDataDTO,
|
||||
meta: {},
|
||||
};
|
||||
return ResponseTransformers.ensureV2Response(dto).spec;
|
||||
}
|
||||
|
||||
export function getRawDashboardChanges(
|
||||
initial: Dashboard,
|
||||
changed: Dashboard,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface DashboardChangeInfo {
|
|||
hasRefreshChange: boolean;
|
||||
isNew?: boolean;
|
||||
hasFolderChanges?: boolean;
|
||||
hasMigratedToV2?: boolean;
|
||||
}
|
||||
|
||||
export function isVersionMismatchError(error?: Error) {
|
||||
|
|
|
|||
|
|
@ -172,9 +172,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
|||
private _serializer: DashboardSceneSerializerLike<
|
||||
Dashboard | DashboardV2Spec,
|
||||
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
|
||||
> = getDashboardSceneSerializer();
|
||||
>;
|
||||
|
||||
public constructor(state: Partial<DashboardSceneState>) {
|
||||
public constructor(state: Partial<DashboardSceneState>, serializerVersion: 'v1' | 'v2' = 'v1') {
|
||||
super({
|
||||
title: 'Dashboard',
|
||||
meta: {},
|
||||
|
|
@ -187,6 +187,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
|||
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
|
||||
});
|
||||
|
||||
this._serializer =
|
||||
serializerVersion === 'v2' ? getDashboardSceneSerializer('v2') : getDashboardSceneSerializer('v1');
|
||||
|
||||
this._changeTracker = new DashboardSceneChangeTracker(this);
|
||||
|
||||
this.addActivationHandler(() => this._activationHandler());
|
||||
|
|
|
|||
|
|
@ -623,10 +623,10 @@ export function ToolbarActions({ dashboard }: Props) {
|
|||
},
|
||||
});
|
||||
|
||||
// Will open a schema v2 editor drawer. Only available with useV2DashboardsAPI feature toggle on.
|
||||
// Will open a schema v2 editor drawer. Only available with new dashboard layouts.
|
||||
toolbarActions.push({
|
||||
group: 'main-buttons',
|
||||
condition: uid && config.featureToggles.useV2DashboardsAPI,
|
||||
condition: uid && dashboardNewLayouts,
|
||||
render: () => {
|
||||
return (
|
||||
<ToolbarButton
|
||||
|
|
|
|||
|
|
@ -60,10 +60,6 @@ jest.mock('@grafana/runtime', () => ({
|
|||
|
||||
describe('DashboardSceneSerializer', () => {
|
||||
describe('v1 schema', () => {
|
||||
beforeEach(() => {
|
||||
config.featureToggles.useV2DashboardsAPI = false;
|
||||
});
|
||||
|
||||
it('Can detect no changes', () => {
|
||||
const dashboard = setup();
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
|
|
@ -456,10 +452,6 @@ describe('DashboardSceneSerializer', () => {
|
|||
});
|
||||
|
||||
describe('v2 schema', () => {
|
||||
beforeEach(() => {
|
||||
config.featureToggles.useV2DashboardsAPI = true;
|
||||
});
|
||||
|
||||
it('Can detect no changes', () => {
|
||||
const dashboard = setupV2();
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { config } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types';
|
||||
|
|
@ -20,11 +19,16 @@ import { getVizPanelKeyForPanelId } from '../utils/utils';
|
|||
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
|
||||
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
|
||||
|
||||
export interface DashboardSceneSerializerLike<T, M> {
|
||||
/**
|
||||
* T is the type of the save model
|
||||
* M is the type of the metadata
|
||||
* I is the type of the initial save model. By default it's the same as T.
|
||||
*/
|
||||
export interface DashboardSceneSerializerLike<T, M, I = T> {
|
||||
/**
|
||||
* The save model which the dashboard scene was originally created from
|
||||
*/
|
||||
initialSaveModel?: T;
|
||||
initialSaveModel?: I;
|
||||
metadata?: M;
|
||||
initializeMapping(saveModel: T | undefined): void;
|
||||
getSaveModel: (s: DashboardScene) => T;
|
||||
|
|
@ -132,6 +136,7 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashb
|
|||
...changeInfo,
|
||||
hasFolderChanges,
|
||||
hasChanges: changeInfo.hasChanges || hasFolderChanges,
|
||||
hasMigratedToV2: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -170,9 +175,14 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashb
|
|||
}
|
||||
|
||||
export class V2DashboardSerializer
|
||||
implements DashboardSceneSerializerLike<DashboardV2Spec, DashboardWithAccessInfo<DashboardV2Spec>['metadata']>
|
||||
implements
|
||||
DashboardSceneSerializerLike<
|
||||
DashboardV2Spec,
|
||||
DashboardWithAccessInfo<DashboardV2Spec>['metadata'],
|
||||
Dashboard | DashboardV2Spec
|
||||
>
|
||||
{
|
||||
initialSaveModel?: DashboardV2Spec;
|
||||
initialSaveModel?: DashboardV2Spec | Dashboard;
|
||||
metadata?: DashboardWithAccessInfo<DashboardV2Spec>['metadata'];
|
||||
protected elementPanelMap = new Map<string, number>();
|
||||
|
||||
|
|
@ -250,6 +260,7 @@ export class V2DashboardSerializer
|
|||
hasFolderChanges,
|
||||
hasChanges: changeInfo.hasChanges || hasFolderChanges,
|
||||
isNew,
|
||||
hasMigratedToV2: !!changeInfo.hasMigratedToV2,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -260,27 +271,30 @@ export class V2DashboardSerializer
|
|||
}
|
||||
|
||||
getTrackingInformation(s: DashboardScene): DashboardTrackingInfo | undefined {
|
||||
const panelPluginIds =
|
||||
Object.values(this.initialSaveModel?.elements ?? [])
|
||||
.filter((e) => e.kind === 'Panel')
|
||||
.map((p) => p.spec.vizConfig.kind) || [];
|
||||
const panels = getPanelPluginCounts(panelPluginIds);
|
||||
const variables = getV2SchemaVariables(this.initialSaveModel?.variables || []);
|
||||
|
||||
if (this.initialSaveModel) {
|
||||
return {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
uid: s.state.uid,
|
||||
title: this.initialSaveModel.title,
|
||||
panels_count: panelPluginIds.length || 0,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: !!this.initialSaveModel.liveNow,
|
||||
...panels,
|
||||
...variables,
|
||||
};
|
||||
if (!this.initialSaveModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
const panelPluginIds =
|
||||
'elements' in this.initialSaveModel
|
||||
? Object.values(this.initialSaveModel.elements)
|
||||
.filter((e) => e.kind === 'Panel')
|
||||
.map((p) => p.spec.vizConfig.kind)
|
||||
: [];
|
||||
const panels = getPanelPluginCounts(panelPluginIds);
|
||||
const variables =
|
||||
'variables' in this.initialSaveModel! ? getV2SchemaVariables(this.initialSaveModel.variables) : [];
|
||||
|
||||
return {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
uid: s.state.uid,
|
||||
title: this.initialSaveModel.title,
|
||||
panels_count: panelPluginIds.length || 0,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: !!this.initialSaveModel.liveNow,
|
||||
...panels,
|
||||
...variables,
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshotUrl() {
|
||||
|
|
@ -288,11 +302,18 @@ export class V2DashboardSerializer
|
|||
}
|
||||
}
|
||||
|
||||
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<
|
||||
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<Dashboard, DashboardMeta>;
|
||||
export function getDashboardSceneSerializer(version: 'v1'): DashboardSceneSerializerLike<Dashboard, DashboardMeta>;
|
||||
export function getDashboardSceneSerializer(
|
||||
version: 'v2'
|
||||
): DashboardSceneSerializerLike<DashboardV2Spec, DashboardWithAccessInfo<DashboardV2Spec>['metadata']>;
|
||||
export function getDashboardSceneSerializer(
|
||||
version?: 'v1' | 'v2'
|
||||
): DashboardSceneSerializerLike<
|
||||
Dashboard | DashboardV2Spec,
|
||||
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
|
||||
> {
|
||||
if (config.featureToggles.useV2DashboardsAPI) {
|
||||
if (version === 'v2') {
|
||||
return new V2DashboardSerializer();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,60 +155,63 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
|||
|
||||
//createLayoutManager(dashboard);
|
||||
|
||||
const dashboardScene = new DashboardScene({
|
||||
description: dashboard.description,
|
||||
editable: dashboard.editable,
|
||||
preload: dashboard.preload,
|
||||
id: dashboardId,
|
||||
isDirty: false,
|
||||
links: dashboard.links,
|
||||
meta,
|
||||
tags: dashboard.tags,
|
||||
title: dashboard.title,
|
||||
uid: metadata.name,
|
||||
version: parseInt(metadata.resourceVersion, 10),
|
||||
body: layoutManager,
|
||||
$timeRange: new SceneTimeRange({
|
||||
from: dashboard.timeSettings.from,
|
||||
to: dashboard.timeSettings.to,
|
||||
fiscalYearStartMonth: dashboard.timeSettings.fiscalYearStartMonth,
|
||||
timeZone: dashboard.timeSettings.timezone,
|
||||
weekStart: dashboard.timeSettings.weekStart,
|
||||
UNSAFE_nowDelay: dashboard.timeSettings.nowDelay,
|
||||
}),
|
||||
$variables: getVariables(dashboard, meta.isSnapshot ?? false),
|
||||
$behaviors: [
|
||||
new behaviors.CursorSync({
|
||||
sync: transformCursorSyncV2ToV1(dashboard.cursorSync),
|
||||
const dashboardScene = new DashboardScene(
|
||||
{
|
||||
description: dashboard.description,
|
||||
editable: dashboard.editable,
|
||||
preload: dashboard.preload,
|
||||
id: dashboardId,
|
||||
isDirty: false,
|
||||
links: dashboard.links,
|
||||
meta,
|
||||
tags: dashboard.tags,
|
||||
title: dashboard.title,
|
||||
uid: metadata.name,
|
||||
version: parseInt(metadata.resourceVersion, 10),
|
||||
body: layoutManager,
|
||||
$timeRange: new SceneTimeRange({
|
||||
from: dashboard.timeSettings.from,
|
||||
to: dashboard.timeSettings.to,
|
||||
fiscalYearStartMonth: dashboard.timeSettings.fiscalYearStartMonth,
|
||||
timeZone: dashboard.timeSettings.timezone,
|
||||
weekStart: dashboard.timeSettings.weekStart,
|
||||
UNSAFE_nowDelay: dashboard.timeSettings.nowDelay,
|
||||
}),
|
||||
new behaviors.SceneQueryController(),
|
||||
registerDashboardMacro,
|
||||
registerPanelInteractionsReporter,
|
||||
new behaviors.LiveNowTimer({ enabled: dashboard.liveNow }),
|
||||
preserveDashboardSceneStateInLocalStorage,
|
||||
addPanelsOnLoadBehavior,
|
||||
new DashboardReloadBehavior({
|
||||
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
|
||||
uid: dashboardId?.toString(),
|
||||
version: 1,
|
||||
$variables: getVariables(dashboard, meta.isSnapshot ?? false),
|
||||
$behaviors: [
|
||||
new behaviors.CursorSync({
|
||||
sync: transformCursorSyncV2ToV1(dashboard.cursorSync),
|
||||
}),
|
||||
new behaviors.SceneQueryController(),
|
||||
registerDashboardMacro,
|
||||
registerPanelInteractionsReporter,
|
||||
new behaviors.LiveNowTimer({ enabled: dashboard.liveNow }),
|
||||
preserveDashboardSceneStateInLocalStorage,
|
||||
addPanelsOnLoadBehavior,
|
||||
new DashboardReloadBehavior({
|
||||
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
|
||||
uid: dashboardId?.toString(),
|
||||
version: 1,
|
||||
}),
|
||||
],
|
||||
$data: new DashboardDataLayerSet({
|
||||
annotationLayers,
|
||||
}),
|
||||
],
|
||||
$data: new DashboardDataLayerSet({
|
||||
annotationLayers,
|
||||
}),
|
||||
controls: new DashboardControls({
|
||||
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: dashboard.timeSettings.quickRanges,
|
||||
controls: new DashboardControls({
|
||||
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: dashboard.timeSettings.quickRanges,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: dashboard.timeSettings.autoRefresh,
|
||||
intervals: dashboard.timeSettings.autoRefreshIntervals,
|
||||
withText: true,
|
||||
}),
|
||||
hideTimeControls: dashboard.timeSettings.hideTimepicker,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: dashboard.timeSettings.autoRefresh,
|
||||
intervals: dashboard.timeSettings.autoRefreshIntervals,
|
||||
withText: true,
|
||||
}),
|
||||
hideTimeControls: dashboard.timeSettings.hideTimepicker,
|
||||
}),
|
||||
});
|
||||
},
|
||||
'v2'
|
||||
);
|
||||
|
||||
dashboardScene.setInitialSaveModel(dto.spec, dto.metadata);
|
||||
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
|
|||
let annotationLayers: SceneDataLayerProvider[] = [];
|
||||
let alertStatesLayer: AlertStatesDataLayer | undefined;
|
||||
const uid = dto.uid;
|
||||
const serializerVersion = config.featureToggles.dashboardNewLayouts ? 'v2' : 'v1';
|
||||
|
||||
if (oldModel.templating?.list?.length) {
|
||||
if (oldModel.meta.isSnapshot) {
|
||||
|
|
@ -246,49 +247,52 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
|
|||
version: oldModel.version,
|
||||
}),
|
||||
];
|
||||
const dashboardScene = new DashboardScene({
|
||||
uid,
|
||||
description: oldModel.description,
|
||||
editable: oldModel.editable,
|
||||
preload: dto.preload ?? false,
|
||||
id: oldModel.id,
|
||||
isDirty: false,
|
||||
links: oldModel.links || [],
|
||||
meta: oldModel.meta,
|
||||
tags: oldModel.tags || [],
|
||||
title: oldModel.title,
|
||||
version: oldModel.version,
|
||||
scopeMeta,
|
||||
body: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
isLazy: !(dto.preload || contextSrv.user.authenticatedBy === 'render'),
|
||||
children: createSceneObjectsForPanels(oldModel.panels),
|
||||
const dashboardScene = new DashboardScene(
|
||||
{
|
||||
uid,
|
||||
description: oldModel.description,
|
||||
editable: oldModel.editable,
|
||||
preload: dto.preload ?? false,
|
||||
id: oldModel.id,
|
||||
isDirty: false,
|
||||
links: oldModel.links || [],
|
||||
meta: oldModel.meta,
|
||||
tags: oldModel.tags || [],
|
||||
title: oldModel.title,
|
||||
version: oldModel.version,
|
||||
scopeMeta,
|
||||
body: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
isLazy: !(dto.preload || contextSrv.user.authenticatedBy === 'render'),
|
||||
children: createSceneObjectsForPanels(oldModel.panels),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
$timeRange: new SceneTimeRange({
|
||||
from: oldModel.time.from,
|
||||
to: oldModel.time.to,
|
||||
fiscalYearStartMonth: oldModel.fiscalYearStartMonth,
|
||||
timeZone: oldModel.timezone,
|
||||
weekStart: isWeekStart(oldModel.weekStart) ? oldModel.weekStart : undefined,
|
||||
UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
|
||||
}),
|
||||
$variables: variables,
|
||||
$behaviors: behaviorList,
|
||||
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
|
||||
controls: new DashboardControls({
|
||||
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: oldModel.timepicker.quick_ranges,
|
||||
$timeRange: new SceneTimeRange({
|
||||
from: oldModel.time.from,
|
||||
to: oldModel.time.to,
|
||||
fiscalYearStartMonth: oldModel.fiscalYearStartMonth,
|
||||
timeZone: oldModel.timezone,
|
||||
weekStart: isWeekStart(oldModel.weekStart) ? oldModel.weekStart : undefined,
|
||||
UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: oldModel.refresh,
|
||||
intervals: oldModel.timepicker.refresh_intervals,
|
||||
withText: true,
|
||||
$variables: variables,
|
||||
$behaviors: behaviorList,
|
||||
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
|
||||
controls: new DashboardControls({
|
||||
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
|
||||
timePicker: new SceneTimePicker({
|
||||
quickRanges: oldModel.timepicker.quick_ranges,
|
||||
}),
|
||||
refreshPicker: new SceneRefreshPicker({
|
||||
refresh: oldModel.refresh,
|
||||
intervals: oldModel.timepicker.refresh_intervals,
|
||||
withText: true,
|
||||
}),
|
||||
hideTimeControls: oldModel.timepicker.hidden,
|
||||
}),
|
||||
hideTimeControls: oldModel.timepicker.hidden,
|
||||
}),
|
||||
});
|
||||
},
|
||||
serializerVersion
|
||||
);
|
||||
|
||||
return dashboardScene;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { Dashboard } from '@grafana/schema/dist/esm/index';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/types.gen';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
|
||||
|
||||
import { UnifiedDashboardAPI } from './UnifiedDashboardAPI';
|
||||
import { DashboardVersionError, DashboardWithAccessInfo } from './types';
|
||||
import { isV2DashboardCommand } from './utils';
|
||||
import { K8sDashboardAPI } from './v1';
|
||||
import { K8sDashboardV2API } from './v2';
|
||||
|
||||
jest.mock('./v1');
|
||||
jest.mock('./v2');
|
||||
|
||||
describe('UnifiedDashboardAPI', () => {
|
||||
let api: UnifiedDashboardAPI;
|
||||
let v1Client: jest.Mocked<K8sDashboardAPI>;
|
||||
let v2Client: jest.Mocked<K8sDashboardV2API>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
api = new UnifiedDashboardAPI();
|
||||
v1Client = api['v1Client'] as jest.Mocked<K8sDashboardAPI>;
|
||||
v2Client = api['v2Client'] as jest.Mocked<K8sDashboardV2API>;
|
||||
});
|
||||
|
||||
describe('getDashboardDTO', () => {
|
||||
it('should try v1 first and return result if successful', async () => {
|
||||
const mockResponse = { dashboard: { title: 'test' } };
|
||||
v1Client.getDashboardDTO.mockResolvedValue(mockResponse as DashboardDTO);
|
||||
|
||||
const result = await api.getDashboardDTO('123');
|
||||
|
||||
expect(result).toBe(mockResponse);
|
||||
expect(v1Client.getDashboardDTO).toHaveBeenCalledWith('123');
|
||||
expect(v2Client.getDashboardDTO).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to v2 if v1 throws DashboardVersionError', async () => {
|
||||
const mockV2Response = { spec: { title: 'test' } };
|
||||
v1Client.getDashboardDTO.mockRejectedValue(new DashboardVersionError('v2alpha1', 'Dashboard is V1 format'));
|
||||
v2Client.getDashboardDTO.mockResolvedValue(mockV2Response as DashboardWithAccessInfo<DashboardV2Spec>);
|
||||
|
||||
const result = await api.getDashboardDTO('123');
|
||||
|
||||
expect(result).toBe(mockV2Response);
|
||||
expect(v2Client.getDashboardDTO).toHaveBeenCalledWith('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveDashboard', () => {
|
||||
it('should use v1 client for v1 dashboard', async () => {
|
||||
const mockCommand = { dashboard: { title: 'test' } };
|
||||
v1Client.saveDashboard.mockResolvedValue({ id: 1, status: 'success', slug: '', uid: '', url: '', version: 1 });
|
||||
|
||||
await api.saveDashboard(mockCommand as SaveDashboardCommand<Dashboard>);
|
||||
|
||||
expect(v1Client.saveDashboard).toHaveBeenCalledWith(mockCommand);
|
||||
expect(v2Client.saveDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use v2 client for v2 dashboard', async () => {
|
||||
const mockCommand: SaveDashboardCommand<DashboardV2Spec> = {
|
||||
dashboard: {
|
||||
title: 'test',
|
||||
elements: {},
|
||||
annotations: [],
|
||||
cursorSync: 'Crosshair',
|
||||
layout: {
|
||||
kind: 'GridLayout',
|
||||
spec: { items: [] },
|
||||
},
|
||||
liveNow: false,
|
||||
tags: [],
|
||||
links: [],
|
||||
preload: false,
|
||||
timeSettings: {
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
autoRefresh: '5s',
|
||||
autoRefreshIntervals: ['5s', '1m', '5m', '15m', '30m', '1h', '4h', '8h', '12h', '24h'],
|
||||
timezone: 'utc',
|
||||
hideTimepicker: false,
|
||||
fiscalYearStartMonth: 0,
|
||||
},
|
||||
variables: [],
|
||||
},
|
||||
};
|
||||
|
||||
v2Client.saveDashboard.mockResolvedValue({ id: 1, status: 'success', slug: '', uid: '', url: '', version: 1 });
|
||||
|
||||
await api.saveDashboard(mockCommand as SaveDashboardCommand<DashboardV2Spec>);
|
||||
|
||||
expect(isV2DashboardCommand(mockCommand)).toBe(true);
|
||||
expect(v2Client.saveDashboard).toHaveBeenCalledWith(mockCommand);
|
||||
expect(v1Client.saveDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDashboard', () => {
|
||||
it('should not try other version if fails', async () => {
|
||||
v1Client.deleteDashboard.mockRejectedValue(new DashboardVersionError('v2alpha1', 'Dashboard is V1 format'));
|
||||
|
||||
try {
|
||||
await api.deleteDashboard('123', true);
|
||||
} catch (error) {}
|
||||
expect(v1Client.deleteDashboard).toHaveBeenCalledWith('123', true);
|
||||
expect(v2Client.deleteDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Dashboard } from '@grafana/schema/dist/esm/index';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
|
||||
|
||||
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
|
||||
import { isV1DashboardCommand, isV2DashboardCommand } from './utils';
|
||||
import { K8sDashboardAPI } from './v1';
|
||||
import { K8sDashboardV2API } from './v2';
|
||||
|
||||
export class UnifiedDashboardAPI
|
||||
implements DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, Dashboard | DashboardV2Spec>
|
||||
{
|
||||
private v1Client: K8sDashboardAPI;
|
||||
private v2Client: K8sDashboardV2API;
|
||||
|
||||
constructor() {
|
||||
this.v1Client = new K8sDashboardAPI();
|
||||
this.v2Client = new K8sDashboardV2API();
|
||||
}
|
||||
|
||||
// Get operation depends on the dashboard format to use one of the two clients
|
||||
async getDashboardDTO(uid: string) {
|
||||
try {
|
||||
return await this.v1Client.getDashboardDTO(uid);
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardVersionError && error.data.storedVersion === 'v2alpha1') {
|
||||
return await this.v2Client.getDashboardDTO(uid);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Save operation depends on the dashboard format to use one of the two clients
|
||||
async saveDashboard(options: SaveDashboardCommand<Dashboard | DashboardV2Spec>) {
|
||||
if (isV2DashboardCommand(options)) {
|
||||
return await this.v2Client.saveDashboard(options);
|
||||
}
|
||||
if (isV1DashboardCommand(options)) {
|
||||
return await this.v1Client.saveDashboard(options);
|
||||
}
|
||||
throw new Error('Invalid dashboard command');
|
||||
}
|
||||
|
||||
// Delete operation for any version is supported in the v1 client
|
||||
async deleteDashboard(uid: string, showSuccessAlert: boolean) {
|
||||
return await this.v1Client.deleteDashboard(uid, showSuccessAlert);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { UnifiedDashboardAPI } from './UnifiedDashboardAPI';
|
||||
import { getDashboardAPI, setDashboardAPI } from './dashboard_api';
|
||||
import { LegacyDashboardAPI } from './legacy';
|
||||
import { K8sDashboardAPI } from './v1';
|
||||
|
|
@ -29,14 +30,29 @@ describe('DashboardApi', () => {
|
|||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
|
||||
});
|
||||
|
||||
it('should use v1 api when and kubernetesDashboards toggle is enabled', () => {
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
|
||||
it('should use legacy when v1 is passed and kubernetesDashboards toggle is disabled', () => {
|
||||
config.featureToggles.kubernetesDashboards = false;
|
||||
expect(getDashboardAPI('v1')).toBeInstanceOf(LegacyDashboardAPI);
|
||||
});
|
||||
|
||||
it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => {
|
||||
config.featureToggles.useV2DashboardsAPI = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2API);
|
||||
it('should use unified api when and kubernetesDashboards toggle is enabled', () => {
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(UnifiedDashboardAPI);
|
||||
});
|
||||
|
||||
it('should return v1 if it is passed in the params', () => {
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI('v1')).toBeInstanceOf(K8sDashboardAPI);
|
||||
});
|
||||
|
||||
it('should return v2 if it is passed in the params', () => {
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI('v2')).toBeInstanceOf(K8sDashboardV2API);
|
||||
});
|
||||
|
||||
it('should throw an error if v2 is passed in the params and kubernetesDashboards toggle is disabled', () => {
|
||||
config.featureToggles.kubernetesDashboards = false;
|
||||
expect(() => getDashboardAPI('v2')).toThrow('v2 is not supported if kubernetes dashboards are disabled');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -60,16 +76,12 @@ describe('DashboardApi', () => {
|
|||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
|
||||
});
|
||||
|
||||
it('should use v1 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => {
|
||||
config.featureToggles.useV2DashboardsAPI = true;
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
|
||||
it('should use v1 when v1 is passed in the params', () => {
|
||||
expect(getDashboardAPI('v1')).toBeInstanceOf(K8sDashboardAPI);
|
||||
});
|
||||
|
||||
it('should use legacy useV2DashboardsAPI toggle is enabled', () => {
|
||||
config.featureToggles.useV2DashboardsAPI = true;
|
||||
config.featureToggles.kubernetesDashboards = undefined;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
|
||||
it('should use v2 when v2 is passed in the params', () => {
|
||||
expect(() => getDashboardAPI('v2')).toThrow('v2 is not supported for legacy architecture');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Dashboard } from '@grafana/schema';
|
|||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
|
||||
import { UnifiedDashboardAPI } from './UnifiedDashboardAPI';
|
||||
import { LegacyDashboardAPI } from './legacy';
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types';
|
||||
import { getDashboardsApiVersion } from './utils';
|
||||
|
|
@ -12,10 +13,9 @@ type DashboardAPIClients = {
|
|||
legacy: DashboardAPI<DashboardDTO, Dashboard>;
|
||||
v1: DashboardAPI<DashboardDTO, Dashboard>;
|
||||
v2: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, DashboardV2Spec>;
|
||||
unified: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, Dashboard | DashboardV2Spec>;
|
||||
};
|
||||
|
||||
type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>;
|
||||
|
||||
let clients: Partial<DashboardAPIClients> | undefined;
|
||||
|
||||
export function setDashboardAPI(override: Partial<DashboardAPIClients> | undefined) {
|
||||
|
|
@ -26,28 +26,28 @@ export function setDashboardAPI(override: Partial<DashboardAPIClients> | undefin
|
|||
}
|
||||
|
||||
// Overloads
|
||||
export function getDashboardAPI(): DashboardAPI<DashboardDTO, Dashboard>;
|
||||
export function getDashboardAPI(): DashboardAPI<
|
||||
DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>,
|
||||
Dashboard | DashboardV2Spec
|
||||
>;
|
||||
export function getDashboardAPI(responseFormat: 'v1'): DashboardAPI<DashboardDTO, Dashboard>;
|
||||
export function getDashboardAPI(
|
||||
requestV2Response: 'v2'
|
||||
responseFormat: 'v2'
|
||||
): DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec>, DashboardV2Spec>;
|
||||
export function getDashboardAPI(
|
||||
requestV2Response?: 'v2'
|
||||
): DashboardAPI<DashboardReturnTypes, Dashboard | DashboardV2Spec> {
|
||||
const v = getDashboardsApiVersion();
|
||||
const isConvertingToV1 = !requestV2Response;
|
||||
responseFormat?: 'v1' | 'v2'
|
||||
): DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, Dashboard | DashboardV2Spec> {
|
||||
const v = getDashboardsApiVersion(responseFormat);
|
||||
|
||||
if (!clients) {
|
||||
clients = {
|
||||
legacy: new LegacyDashboardAPI(),
|
||||
v1: new K8sDashboardAPI(),
|
||||
v2: new K8sDashboardV2API(isConvertingToV1),
|
||||
v2: new K8sDashboardV2API(),
|
||||
unified: new UnifiedDashboardAPI(),
|
||||
};
|
||||
}
|
||||
|
||||
if (v === 'v2' && requestV2Response === 'v2') {
|
||||
return new K8sDashboardV2API(false);
|
||||
}
|
||||
|
||||
if (!clients[v]) {
|
||||
throw new Error(`Unknown Dashboard API version: ${v}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { UrlQueryMap } from '@grafana/data';
|
||||
import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen';
|
||||
import { Resource } from 'app/features/apiserver/types';
|
||||
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
|
||||
import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types';
|
||||
|
|
@ -15,7 +16,7 @@ export interface DashboardAPI<G, T> {
|
|||
}
|
||||
|
||||
// Implemented using /api/dashboards/*
|
||||
export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAccessInfo'> {
|
||||
export interface DashboardWithAccessInfo<T> extends Resource<T, Status, 'DashboardWithAccessInfo'> {
|
||||
access: {
|
||||
url?: string;
|
||||
slug?: string;
|
||||
|
|
@ -28,3 +29,25 @@ export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAc
|
|||
annotationsPermissions?: AnnotationsPermissions;
|
||||
}; // TODO...
|
||||
}
|
||||
|
||||
export interface DashboardVersionError extends Error {
|
||||
status: number;
|
||||
data: {
|
||||
// The version which was stored when the dashboard was created / updated.
|
||||
// Currently known versions are: 'v2alpha1' | 'v1alpha1' | 'v0alpha1'
|
||||
storedVersion: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class DashboardVersionError extends Error {
|
||||
constructor(storedVersion: string, message = 'Dashboard version mismatch') {
|
||||
super(message);
|
||||
this.name = 'DashboardVersionError';
|
||||
this.status = 200;
|
||||
this.data = {
|
||||
storedVersion,
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,27 +23,17 @@ describe('getDashboardsApiVersion', () => {
|
|||
expect(getDashboardsApiVersion()).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should return v2 when dashboardScene is enabled and useV2DashboardsAPI is enabled', () => {
|
||||
it('should return unified when dashboardScene is enabled and kubernetesDashboards is enabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: true,
|
||||
useV2DashboardsAPI: true,
|
||||
};
|
||||
expect(getDashboardsApiVersion()).toBe('v2');
|
||||
});
|
||||
|
||||
it('should return v1 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: true,
|
||||
useV2DashboardsAPI: false,
|
||||
kubernetesDashboards: true,
|
||||
};
|
||||
expect(getDashboardsApiVersion()).toBe('v1');
|
||||
expect(getDashboardsApiVersion()).toBe('unified');
|
||||
});
|
||||
|
||||
it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => {
|
||||
it('should return legacy when dashboardScene is enabled and kubernetesDashboards is disabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: true,
|
||||
useV2DashboardsAPI: false,
|
||||
kubernetesDashboards: false,
|
||||
};
|
||||
expect(getDashboardsApiVersion()).toBe('legacy');
|
||||
|
|
@ -57,20 +47,16 @@ describe('getDashboardsApiVersion', () => {
|
|||
it('should return legacy when kubernetesDashboards is disabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: false,
|
||||
useV2DashboardsAPI: false,
|
||||
kubernetesDashboards: false,
|
||||
};
|
||||
|
||||
expect(getDashboardsApiVersion()).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should return legacy when kubernetesDashboards is disabled', () => {
|
||||
it('should return v1 when kubernetesDashboards is enabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: false,
|
||||
useV2DashboardsAPI: false,
|
||||
kubernetesDashboards: true,
|
||||
};
|
||||
|
||||
expect(getDashboardsApiVersion()).toBe('v1');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,27 +7,34 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types';
|
|||
|
||||
import { DashboardWithAccessInfo } from './types';
|
||||
|
||||
export const GRID_ROW_HEIGHT = 1;
|
||||
|
||||
export function getDashboardsApiVersion() {
|
||||
export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') {
|
||||
const isDashboardSceneEnabled = config.featureToggles.dashboardScene;
|
||||
const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards;
|
||||
const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false';
|
||||
|
||||
// if dashboard scene is disabled, use legacy API response for the old architecture
|
||||
if (!config.featureToggles.dashboardScene || forcingOldDashboardArch) {
|
||||
// for old architecture, use v1 API for k8s dashboards
|
||||
if (config.featureToggles.kubernetesDashboards) {
|
||||
return 'v1';
|
||||
// Force legacy API when dashboard scene is disabled or explicitly forced
|
||||
if (!isDashboardSceneEnabled || forcingOldDashboardArch) {
|
||||
if (responseFormat === 'v2') {
|
||||
throw new Error('v2 is not supported for legacy architecture');
|
||||
}
|
||||
|
||||
return 'legacy';
|
||||
return isKubernetesDashboardsEnabled ? 'v1' : 'legacy';
|
||||
}
|
||||
|
||||
if (config.featureToggles.useV2DashboardsAPI) {
|
||||
return 'v2';
|
||||
// Unified manages redirection between v1 and v2, but when responseFormat is undefined we get the unified API
|
||||
if (isKubernetesDashboardsEnabled) {
|
||||
if (responseFormat === 'v1') {
|
||||
return 'v1';
|
||||
}
|
||||
if (responseFormat === 'v2') {
|
||||
return 'v2';
|
||||
}
|
||||
return 'unified';
|
||||
}
|
||||
|
||||
if (config.featureToggles.kubernetesDashboards) {
|
||||
return 'v1';
|
||||
// Handle non-kubernetes case
|
||||
if (responseFormat === 'v2') {
|
||||
throw new Error('v2 is not supported if kubernetes dashboards are disabled');
|
||||
}
|
||||
|
||||
return 'legacy';
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/ty
|
|||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
|
||||
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types';
|
||||
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
|
||||
|
||||
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
|
||||
private client: ResourceClient<DashboardDataDTO>;
|
||||
|
|
@ -96,6 +96,11 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
|
|||
try {
|
||||
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto');
|
||||
|
||||
// This could come as conversion error from v0 or v2 to V1.
|
||||
if (dash.status?.conversion?.failed) {
|
||||
throw new DashboardVersionError(dash.status.conversion.storedVersion, dash.status.conversion.error);
|
||||
}
|
||||
|
||||
const result: DashboardDTO = {
|
||||
meta: {
|
||||
...dash.access,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe('v2 dashboard API', () => {
|
|||
updatedBy: '',
|
||||
});
|
||||
|
||||
const api = new K8sDashboardV2API(false);
|
||||
const api = new K8sDashboardV2API();
|
||||
// because the API can currently return both DashboardDTO and DashboardWithAccessInfo<DashboardV2Spec> based on the
|
||||
// parameter convertToV1, we need to cast the result to DashboardWithAccessInfo<DashboardV2Spec> to be able to
|
||||
// access
|
||||
|
|
@ -96,7 +96,7 @@ describe('v2 dashboard API', () => {
|
|||
it('throws an error if folder is not found', async () => {
|
||||
jest.spyOn(backendSrv, 'getFolderByUid').mockRejectedValue({ message: 'folder not found', status: 'not-found' });
|
||||
|
||||
const api = new K8sDashboardV2API(false);
|
||||
const api = new K8sDashboardV2API();
|
||||
await expect(api.getDashboardDTO('test')).rejects.toThrow('Failed to load folder');
|
||||
});
|
||||
});
|
||||
|
|
@ -123,7 +123,7 @@ describe('v2 dashboard API - Save', () => {
|
|||
};
|
||||
|
||||
it('should create new dashboard', async () => {
|
||||
const api = new K8sDashboardV2API(false);
|
||||
const api = new K8sDashboardV2API();
|
||||
const result = await api.saveDashboard({
|
||||
...defaultSaveCommand,
|
||||
dashboard: {
|
||||
|
|
@ -143,7 +143,7 @@ describe('v2 dashboard API - Save', () => {
|
|||
});
|
||||
|
||||
it('should update existing dashboard', async () => {
|
||||
const api = new K8sDashboardV2API(false);
|
||||
const api = new K8sDashboardV2API();
|
||||
|
||||
const result = await api.saveDashboard({
|
||||
...defaultSaveCommand,
|
||||
|
|
@ -160,7 +160,7 @@ describe('v2 dashboard API - Save', () => {
|
|||
});
|
||||
|
||||
it('should update existing dashboard that is store in a folder', async () => {
|
||||
const api = new K8sDashboardV2API(false);
|
||||
const api = new K8sDashboardV2API();
|
||||
await api.saveDashboard({
|
||||
dashboard: {
|
||||
...defaultSaveCommand.dashboard,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { locationUtil, UrlQueryMap } from '@grafana/data';
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
|
||||
|
|
@ -21,15 +21,14 @@ import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types';
|
|||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
|
||||
|
||||
import { ResponseTransformers } from './ResponseTransformers';
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types';
|
||||
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
|
||||
|
||||
export class K8sDashboardV2API
|
||||
implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO, DashboardV2Spec>
|
||||
{
|
||||
private client: ResourceClient<DashboardV2Spec>;
|
||||
|
||||
constructor(private convertToV1: boolean) {
|
||||
constructor() {
|
||||
this.client = new ScopedResourceClient<DashboardV2Spec>({
|
||||
group: 'dashboard.grafana.app',
|
||||
version: 'v2alpha1',
|
||||
|
|
@ -37,40 +36,32 @@ export class K8sDashboardV2API
|
|||
});
|
||||
}
|
||||
|
||||
async getDashboardDTO(uid: string, params?: UrlQueryMap) {
|
||||
async getDashboardDTO(uid: string) {
|
||||
try {
|
||||
const dashboard = await this.client.subresource<DashboardWithAccessInfo<DashboardV2Spec>>(uid, 'dto');
|
||||
|
||||
let result: DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO | undefined;
|
||||
|
||||
// TODO: For dev purposes only, the conversion should and will happen in the API. This is just to stub v2 api responses.
|
||||
result = ResponseTransformers.ensureV2Response(dashboard);
|
||||
if (dashboard.status?.conversion?.failed) {
|
||||
throw new DashboardVersionError(dashboard.status.conversion.storedVersion, dashboard.status.conversion.error);
|
||||
}
|
||||
|
||||
// load folder info if available
|
||||
if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) {
|
||||
if (dashboard.metadata.annotations && dashboard.metadata.annotations[AnnoKeyFolder]) {
|
||||
try {
|
||||
const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]);
|
||||
result.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
|
||||
result.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
|
||||
result.metadata.annotations[AnnoKeyFolderId] = folder.id;
|
||||
const folder = await backendSrv.getFolderByUid(dashboard.metadata.annotations[AnnoKeyFolder]);
|
||||
dashboard.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
|
||||
dashboard.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
|
||||
dashboard.metadata.annotations[AnnoKeyFolderId] = folder.id;
|
||||
} catch (e) {
|
||||
throw new Error('Failed to load folder');
|
||||
}
|
||||
} else if (result.metadata.annotations && !result.metadata.annotations[AnnoKeyFolder]) {
|
||||
} else if (dashboard.metadata.annotations && !dashboard.metadata.annotations[AnnoKeyFolder]) {
|
||||
// Set AnnoKeyFolder to empty string for top-level dashboards
|
||||
// This ensures NestedFolderPicker correctly identifies it as being in the "Dashboard" root folder
|
||||
// AnnoKeyFolder undefined -> top-level dashboard -> empty string
|
||||
result.metadata.annotations[AnnoKeyFolder] = '';
|
||||
dashboard.metadata.annotations[AnnoKeyFolder] = '';
|
||||
}
|
||||
|
||||
// Depending on the ui components readiness, we might need to convert the response to v1
|
||||
if (this.convertToV1) {
|
||||
// Always return V1 format
|
||||
result = ResponseTransformers.ensureV1Response(result);
|
||||
return result;
|
||||
}
|
||||
// return the v2 response
|
||||
return result;
|
||||
return dashboard;
|
||||
} catch (e) {
|
||||
const status = getStatusFromError(e);
|
||||
const message = getMessageFromError(e);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ReactElement } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { Box, Spinner, Stack } from '@grafana/ui';
|
||||
import { Alert, Box, Spinner, Stack } from '@grafana/ui';
|
||||
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||
|
||||
import { DiffGroup } from '../../../dashboard-scene/settings/version-history/DiffGroup';
|
||||
|
|
@ -16,6 +16,7 @@ interface SaveDashboardDiffProps {
|
|||
hasFolderChanges?: boolean;
|
||||
oldFolder?: string;
|
||||
newFolder?: string;
|
||||
hasMigratedToV2?: boolean;
|
||||
}
|
||||
|
||||
export const SaveDashboardDiff = ({
|
||||
|
|
@ -25,6 +26,7 @@ export const SaveDashboardDiff = ({
|
|||
hasFolderChanges,
|
||||
oldFolder,
|
||||
newFolder,
|
||||
hasMigratedToV2,
|
||||
}: SaveDashboardDiffProps) => {
|
||||
const loader = useAsync(async () => {
|
||||
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
|
||||
|
|
@ -61,6 +63,16 @@ export const SaveDashboardDiff = ({
|
|||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
{hasMigratedToV2 && (
|
||||
<Box paddingTop={1}>
|
||||
<Alert
|
||||
title={
|
||||
'The diff is hard to read because the dashboard has been migrated to the new Grafana dashboard format'
|
||||
}
|
||||
severity="info"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{hasFolderChanges && (
|
||||
<DiffGroup
|
||||
diffs={[
|
||||
|
|
@ -80,7 +92,7 @@ export const SaveDashboardDiff = ({
|
|||
{(!value || !oldValue) && <Spinner />}
|
||||
{value && value.count >= 1 ? (
|
||||
<>
|
||||
{value && value.schemaChange && value.schemaChange}
|
||||
{!hasMigratedToV2 && value && value.schemaChange && value.schemaChange}
|
||||
{value && value.showDiffs && value.diffs}
|
||||
<Box paddingTop={1}>
|
||||
<h4>Full JSON diff</h4>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import DashboardScenePage from 'app/features/dashboard-scene/pages/DashboardScen
|
|||
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
import { DashboardRoutes } from 'app/types';
|
||||
|
||||
import { isDashboardV2Resource } from '../api/utils';
|
||||
|
||||
import DashboardPage, { DashboardPageParams } from './DashboardPage';
|
||||
import { DashboardPageError } from './DashboardPageError';
|
||||
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types';
|
||||
|
|
@ -23,18 +25,12 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
|
|||
const forceOld = props.queryParams.scenes === false;
|
||||
const params = useParams<DashboardPageParams>();
|
||||
const location = useLocation();
|
||||
|
||||
// Force scenes if v2 api and scenes are enabled
|
||||
if (config.featureToggles.useV2DashboardsAPI && config.featureToggles.dashboardScene && !forceOld) {
|
||||
console.log('DashboardPageProxy: forcing scenes because of v2 api');
|
||||
return <DashboardScenePage {...props} />;
|
||||
}
|
||||
const stateManager = getDashboardScenePageStateManager();
|
||||
|
||||
if (forceScenes || (config.featureToggles.dashboardScene && !forceOld)) {
|
||||
return <DashboardScenePage {...props} />;
|
||||
}
|
||||
|
||||
const stateManager = getDashboardScenePageStateManager();
|
||||
const isScenesSupportedRoute = Boolean(
|
||||
props.route.routeName === DashboardRoutes.Home || (props.route.routeName === DashboardRoutes.Normal && params.uid)
|
||||
);
|
||||
|
|
@ -63,7 +59,17 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (dashboard?.value?.dashboard?.uid !== params.uid && dashboard.value?.meta?.isNew !== true) {
|
||||
const uid =
|
||||
dashboard.value && isDashboardV2Resource(dashboard.value)
|
||||
? dashboard.value.metadata.name
|
||||
: dashboard.value?.meta.uid;
|
||||
const canEdit =
|
||||
dashboard.value && isDashboardV2Resource(dashboard.value)
|
||||
? dashboard.value?.access.canEdit
|
||||
: dashboard.value?.meta?.canEdit || dashboard.value?.meta?.canMakeEditable;
|
||||
const isNew = !uid;
|
||||
|
||||
if (uid !== params.uid && !isNew) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -71,11 +77,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
|
|||
return <DashboardPage {...props} params={params} location={location} />;
|
||||
}
|
||||
|
||||
if (
|
||||
dashboard.value &&
|
||||
!(dashboard.value.meta?.canEdit || dashboard.value.meta?.canMakeEditable) &&
|
||||
isScenesSupportedRoute
|
||||
) {
|
||||
if (!canEdit && isScenesSupportedRoute && !forceOld) {
|
||||
return <DashboardScenePage {...props} />;
|
||||
} else {
|
||||
return <DashboardPage {...props} params={params} location={location} />;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { DashboardDTO } from 'app/types';
|
|||
import { appEvents } from '../../../core/core';
|
||||
import { ResponseTransformers } from '../api/ResponseTransformers';
|
||||
import { getDashboardAPI } from '../api/dashboard_api';
|
||||
import { DashboardWithAccessInfo } from '../api/types';
|
||||
import { DashboardVersionError, DashboardWithAccessInfo } from '../api/types';
|
||||
|
||||
import { getDashboardSrv } from './DashboardSrv';
|
||||
import { getDashboardSnapshotSrv } from './SnapshotSrv';
|
||||
|
|
@ -118,7 +118,7 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
|
|||
uid: string | undefined,
|
||||
params?: UrlQueryMap
|
||||
): Promise<DashboardDTO> {
|
||||
const stateManager = getDashboardScenePageStateManager();
|
||||
const stateManager = getDashboardScenePageStateManager('v1');
|
||||
let promise;
|
||||
|
||||
if (type === 'script' && slug) {
|
||||
|
|
@ -139,11 +139,14 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
|
|||
}
|
||||
}
|
||||
|
||||
promise = getDashboardAPI()
|
||||
promise = getDashboardAPI('v1')
|
||||
.getDashboardDTO(uid, params)
|
||||
.then((result) => {
|
||||
return result;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Failed to load dashboard', e);
|
||||
if (isFetchError(e)) {
|
||||
if (isFetchError(e) && !(e instanceof DashboardVersionError)) {
|
||||
e.isHandled = true;
|
||||
if (e.status === 404) {
|
||||
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
|
||||
|
|
@ -204,9 +207,12 @@ export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase<DashboardWithAc
|
|||
|
||||
promise = getDashboardAPI('v2')
|
||||
.getDashboardDTO(uid, params)
|
||||
.then((result) => {
|
||||
return result;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Failed to load dashboard', e);
|
||||
if (isFetchError(e)) {
|
||||
if (isFetchError(e) && !(e instanceof DashboardVersionError)) {
|
||||
e.isHandled = true;
|
||||
if (e.status === 404) {
|
||||
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ async function fetchDashboard(
|
|||
try {
|
||||
switch (args.routeName) {
|
||||
case DashboardRoutes.Home: {
|
||||
const stateManager = getDashboardScenePageStateManager();
|
||||
const stateManager = getDashboardScenePageStateManager('v1');
|
||||
const cachedDashboard = stateManager.getDashboardFromCache(HOME_DASHBOARD_CACHE_KEY);
|
||||
|
||||
if (cachedDashboard) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { t } from 'app/core/internationalization';
|
||||
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';
|
||||
|
||||
|
|
@ -49,7 +51,12 @@ export const validateUid = (value: string) => {
|
|||
return getDashboardAPI()
|
||||
.getDashboardDTO(value)
|
||||
.then((existingDashboard) => {
|
||||
return `Dashboard named '${existingDashboard?.dashboard.title}' in folder '${existingDashboard?.meta.folderTitle}' has the same UID`;
|
||||
const isV2 = isDashboardV2Resource(existingDashboard);
|
||||
const dashboard = isV2 ? existingDashboard.spec : existingDashboard.dashboard;
|
||||
const folderTitle = isV2
|
||||
? existingDashboard.metadata.annotations?.[AnnoKeyFolderTitle]
|
||||
: existingDashboard.meta.folderTitle;
|
||||
return `Dashboard named '${dashboard.title}' in folder '${folderTitle}' has the same UID`;
|
||||
})
|
||||
.catch((error) => {
|
||||
error.isHandled = true;
|
||||
|
|
|
|||
Loading…
Reference in New Issue