Compare commits

...

11 Commits

Author SHA1 Message Date
oscarkilhed
ba29e8f6ba remove requirement doc from file tree 2025-11-26 17:03:47 +01:00
oscarkilhed
16dcaa9f58 don't set panel datasource on panels with no queries 2025-11-26 13:57:00 +01:00
oscarkilhed
bf29c235f8 fix failing tests 2025-11-26 13:37:27 +01:00
oscarkilhed
ee95eacbb8 fix: correct v2->v1 datasource conversion for roundtrip preservation
- Fix datasource path extraction: use q.spec.query.datasource?.name (not q.spec.query.spec.datasource?.uid)
- Fix datasource type extraction: use q.spec.query.group (not q.spec.query.spec.group)
- Reconstruct panel-level datasource from query datasources:
  - All queries share same datasource → set as panel datasource, queries inherit
  - Queries have different datasources → set panel to '-- Mixed --', queries have individual datasources
  - No datasources → use default datasource
- Only include datasource on queries when they differ from panel datasource
- Add comprehensive tests for all datasource conversion scenarios
- Update conversion requirements documentation with datasource conversion rules

This ensures V1→V2→V1 roundtrip preserves datasource structure correctly.
2025-11-25 16:44:08 +01:00
oscarkilhed
6bb51bfb17 Rename flatten* functions to convert* for layout-to-panel conversion
Rename flattenGridLayoutToV1Panels to convertGridLayoutToV1Panels and
flattenAutoGridLayoutToV1Panels to convertAutoGridLayoutToV1Panels to
better reflect that these functions convert V2 layout structures to V1
panel arrays, rather than flattening nested structures.
2025-11-25 16:25:05 +01:00
oscarkilhed
22ed894bce refactor: remove unnecessary catch block in DashboardLoaderSrv
Remove redundant catch block that was re-throwing the original error.
The error will propagate naturally without the catch block.
2025-11-25 13:04:18 +01:00
oscarkilhed
559483c027 refactor: extract layout handler functions for better testability
- Extract each layout type case from flattenLayoutToV1Panels switch into separate functions:
  - flattenGridLayoutToV1Panels
  - flattenRowsLayoutToV1Panels
  - flattenTabsLayoutToV1Panels
  - flattenAutoGridLayoutToV1Panels
- Export all layout handler functions for direct unit testing
- Add comprehensive unit tests for each layout handler function
- Remove redundant integration tests that are now covered by unit tests
- Update V2_TO_V1_CONVERSION_REQUIREMENTS.md to document new function structure

This refactoring makes the code more testable and maintainable while preserving all existing functionality.
2025-11-25 13:03:29 +01:00
oscarkilhed
036a7fb16f add test for tabs in rows ordering 2025-11-24 20:42:33 +01:00
oscarkilhed
f22172c6b4 Add requirement for y position 2025-11-24 20:34:51 +01:00
oscarkilhed
851a83b8e9 fix manual testing in 2025-11-24 20:12:03 +01:00
oscarkilhed
441a9c7771 first working conversion from v2-v1 2025-11-24 20:07:55 +01:00
8 changed files with 2973 additions and 36 deletions

View File

@@ -2185,9 +2185,6 @@
"public/app/features/dashboard/api/ResponseTransformers.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2
},
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx": {

View File

@@ -75,7 +75,6 @@ describe('ResponseTransformers', () => {
describe('getDefaultDataSource', () => {
it('should return prometheus as default', () => {
expect(getDefaultDatasource()).toEqual({
apiVersion: 'v2',
uid: 'xyz-abc',
type: 'prometheus',
});
@@ -1049,13 +1048,21 @@ describe('ResponseTransformers', () => {
expect(v1.links).toEqual(v2Spec.links);
expect(v1.targets).toEqual(
v2Spec.data.spec.queries.map((q) => {
const queryDs = {
type: q.spec.query.group,
uid: q.spec.query.datasource?.name,
};
// Only include datasource if it differs from panel datasource
// (This test uses handyTestingSchema which may have queries with different datasources)
const panelDs = v1.datasource;
const queryDiffers =
!panelDs ||
(queryDs.uid && queryDs.uid !== panelDs.uid) ||
(!queryDs.uid && queryDs.type && queryDs.type !== panelDs.type);
return {
refId: q.spec.refId,
hide: q.spec.hidden,
datasource: {
type: q.spec.query.spec.group,
uid: q.spec.query.spec.datasource?.uid,
},
...(queryDiffers && { datasource: queryDs }),
...q.spec.query.spec,
};
})
@@ -1173,4 +1180,789 @@ describe('ResponseTransformers', () => {
expect(v2.spec.options).toEqual(v1.options);
}
}
describe('v2 -> v1 datasource conversion', () => {
describe('transformV2PanelToV1Panel datasource handling', () => {
it('should set panel datasource when all queries share the same datasource', () => {
const v2Panel: PanelKind = {
kind: 'Panel',
spec: {
id: 1,
title: 'Test Panel',
description: '',
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
fieldConfig: { defaults: {}, overrides: [] },
options: {},
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'prometheus',
datasource: {
name: 'prometheus-uid',
},
spec: { expr: 'up' },
},
},
},
{
kind: 'PanelQuery',
spec: {
refId: 'B',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'prometheus',
datasource: {
name: 'prometheus-uid',
},
spec: { expr: 'down' },
},
},
},
],
transformations: [],
queryOptions: {},
},
},
links: [],
},
};
const layoutItem: GridLayoutItemKind = {
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
};
const v1Panel = ResponseTransformers.ensureV1Response({
apiVersion: 'v2beta1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'test',
resourceVersion: '1',
creationTimestamp: '',
annotations: {},
labels: {},
},
spec: {
title: 'Test',
description: '',
tags: [],
cursorSync: 'Off',
preload: false,
liveNow: false,
editable: true,
timeSettings: {
from: 'now-6h',
to: 'now',
timezone: 'browser',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
weekStart: 'monday',
},
links: [],
annotations: [],
variables: [],
elements: {
'panel-1': v2Panel,
},
layout: {
kind: 'GridLayout',
spec: {
items: [layoutItem],
},
},
},
access: {
url: '/d/test',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
canShare: true,
canStar: true,
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
organization: { canAdd: true, canEdit: true, canDelete: true },
},
},
});
const panel = v1Panel.dashboard.panels?.[0];
expect(panel).toBeDefined();
expect(panel?.datasource).toEqual({
uid: 'prometheus-uid',
type: 'prometheus',
});
// Queries should not have datasource since it matches panel datasource
expect(panel?.targets?.[0].datasource).toBeUndefined();
expect(panel?.targets?.[1].datasource).toBeUndefined();
});
it('should set panel datasource to mixed when queries have different datasources', () => {
const v2Panel: PanelKind = {
kind: 'Panel',
spec: {
id: 1,
title: 'Test Panel',
description: '',
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
fieldConfig: { defaults: {}, overrides: [] },
options: {},
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'prometheus',
datasource: {
name: 'prometheus-uid',
},
spec: { expr: 'up' },
},
},
},
{
kind: 'PanelQuery',
spec: {
refId: 'B',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'loki',
datasource: {
name: 'loki-uid',
},
spec: { expr: 'count_over_time({job="test"}[5m])' },
},
},
},
],
transformations: [],
queryOptions: {},
},
},
links: [],
},
};
const layoutItem: GridLayoutItemKind = {
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
};
const v1Panel = ResponseTransformers.ensureV1Response({
apiVersion: 'v2beta1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'test',
resourceVersion: '1',
creationTimestamp: '',
annotations: {},
labels: {},
},
spec: {
title: 'Test',
description: '',
tags: [],
cursorSync: 'Off',
preload: false,
liveNow: false,
editable: true,
timeSettings: {
from: 'now-6h',
to: 'now',
timezone: 'browser',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
weekStart: 'monday',
},
links: [],
annotations: [],
variables: [],
elements: {
'panel-1': v2Panel,
},
layout: {
kind: 'GridLayout',
spec: {
items: [layoutItem],
},
},
},
access: {
url: '/d/test',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
canShare: true,
canStar: true,
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
organization: { canAdd: true, canEdit: true, canDelete: true },
},
},
});
const panel = v1Panel.dashboard.panels?.[0];
expect(panel).toBeDefined();
expect(panel?.datasource).toEqual({
uid: '-- Mixed --',
});
// Queries should have their own datasources when panel is mixed
expect(panel?.targets?.[0].datasource).toEqual({
uid: 'prometheus-uid',
type: 'prometheus',
});
expect(panel?.targets?.[1].datasource).toEqual({
uid: 'loki-uid',
type: 'loki',
});
});
it('should use default datasource when queries have no datasource', () => {
const v2Panel: PanelKind = {
kind: 'Panel',
spec: {
id: 1,
title: 'Test Panel',
description: '',
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
fieldConfig: { defaults: {}, overrides: [] },
options: {},
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: '',
spec: { expr: 'up' },
},
},
},
],
transformations: [],
queryOptions: {},
},
},
links: [],
},
};
const layoutItem: GridLayoutItemKind = {
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
};
const v1Panel = ResponseTransformers.ensureV1Response({
apiVersion: 'v2beta1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'test',
resourceVersion: '1',
creationTimestamp: '',
annotations: {},
labels: {},
},
spec: {
title: 'Test',
description: '',
tags: [],
cursorSync: 'Off',
preload: false,
liveNow: false,
editable: true,
timeSettings: {
from: 'now-6h',
to: 'now',
timezone: 'browser',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
weekStart: 'monday',
},
links: [],
annotations: [],
variables: [],
elements: {
'panel-1': v2Panel,
},
layout: {
kind: 'GridLayout',
spec: {
items: [layoutItem],
},
},
},
access: {
url: '/d/test',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
canShare: true,
canStar: true,
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
organization: { canAdd: true, canEdit: true, canDelete: true },
},
},
});
const panel = v1Panel.dashboard.panels?.[0];
expect(panel).toBeDefined();
// Should use default datasource
expect(panel?.datasource).toEqual({
uid: 'xyz-abc',
type: 'prometheus',
});
});
it('should handle query with datasource that differs from panel datasource', () => {
const v2Panel: PanelKind = {
kind: 'Panel',
spec: {
id: 1,
title: 'Test Panel',
description: '',
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
fieldConfig: { defaults: {}, overrides: [] },
options: {},
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'prometheus',
datasource: {
name: 'prometheus-uid',
},
spec: { expr: 'up' },
},
},
},
{
kind: 'PanelQuery',
spec: {
refId: 'B',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'prometheus',
datasource: {
name: 'prometheus-uid',
},
spec: { expr: 'down' },
},
},
},
{
kind: 'PanelQuery',
spec: {
refId: 'C',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'loki',
datasource: {
name: 'loki-uid',
},
spec: { expr: 'count_over_time({job="test"}[5m])' },
},
},
},
],
transformations: [],
queryOptions: {},
},
},
links: [],
},
};
const layoutItem: GridLayoutItemKind = {
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
};
const v1Panel = ResponseTransformers.ensureV1Response({
apiVersion: 'v2beta1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'test',
resourceVersion: '1',
creationTimestamp: '',
annotations: {},
labels: {},
},
spec: {
title: 'Test',
description: '',
tags: [],
cursorSync: 'Off',
preload: false,
liveNow: false,
editable: true,
timeSettings: {
from: 'now-6h',
to: 'now',
timezone: 'browser',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
weekStart: 'monday',
},
links: [],
annotations: [],
variables: [],
elements: {
'panel-1': v2Panel,
},
layout: {
kind: 'GridLayout',
spec: {
items: [layoutItem],
},
},
},
access: {
url: '/d/test',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
canShare: true,
canStar: true,
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
organization: { canAdd: true, canEdit: true, canDelete: true },
},
},
});
const panel = v1Panel.dashboard.panels?.[0];
expect(panel).toBeDefined();
// Should be mixed since queries have different datasources
expect(panel?.datasource).toEqual({
uid: '-- Mixed --',
});
// All queries should have their datasources
expect(panel?.targets?.[0].datasource).toEqual({
uid: 'prometheus-uid',
type: 'prometheus',
});
expect(panel?.targets?.[1].datasource).toEqual({
uid: 'prometheus-uid',
type: 'prometheus',
});
expect(panel?.targets?.[2].datasource).toEqual({
uid: 'loki-uid',
type: 'loki',
});
});
it('should handle queries with only type (no UID)', () => {
const v2Panel: PanelKind = {
kind: 'Panel',
spec: {
id: 1,
title: 'Test Panel',
description: '',
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
fieldConfig: { defaults: {}, overrides: [] },
options: {},
},
},
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: 'v0',
group: 'prometheus',
// No datasource.name (no UID), only type in group
spec: { expr: 'up' },
},
},
},
],
transformations: [],
queryOptions: {},
},
},
links: [],
},
};
const layoutItem: GridLayoutItemKind = {
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
};
const v1Panel = ResponseTransformers.ensureV1Response({
apiVersion: 'v2beta1',
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'test',
resourceVersion: '1',
creationTimestamp: '',
annotations: {},
labels: {},
},
spec: {
title: 'Test',
description: '',
tags: [],
cursorSync: 'Off',
preload: false,
liveNow: false,
editable: true,
timeSettings: {
from: 'now-6h',
to: 'now',
timezone: 'browser',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
weekStart: 'monday',
},
links: [],
annotations: [],
variables: [],
elements: {
'panel-1': v2Panel,
},
layout: {
kind: 'GridLayout',
spec: {
items: [layoutItem],
},
},
},
access: {
url: '/d/test',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
canShare: true,
canStar: true,
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
organization: { canAdd: true, canEdit: true, canDelete: true },
},
},
});
const panel = v1Panel.dashboard.panels?.[0];
expect(panel).toBeDefined();
// Should use type from group as panel datasource type
expect(panel?.datasource).toEqual({
type: 'prometheus',
});
// Query should not have datasource since it matches panel (by type)
expect(panel?.targets?.[0].datasource).toBeUndefined();
});
it('should preserve datasources correctly in roundtrip v1->v2->v1', () => {
// Start with v1 panel
const v1Input: Panel = {
id: 1,
type: 'timeseries',
title: 'Test Panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
targets: [
{
refId: 'A',
expr: 'up',
datasource: {
uid: 'prometheus-uid',
type: 'prometheus',
},
},
{
refId: 'B',
expr: 'down',
// No datasource - should inherit from panel
},
],
datasource: {
uid: 'prometheus-uid',
type: 'prometheus',
},
fieldConfig: { defaults: {}, overrides: [] },
options: {},
links: [],
transformations: [],
};
// Convert v1 -> v2
const v2Dashboard = ResponseTransformers.ensureV2Response({
dashboard: {
uid: 'test',
title: 'Test',
panels: [v1Input],
tags: [],
schemaVersion: 40,
time: { from: 'now-6h', to: 'now' },
annotations: { list: [] },
templating: { list: [] },
links: [],
},
meta: {
url: '/d/test',
slug: 'test',
canSave: true,
canEdit: true,
canDelete: true,
canShare: true,
canStar: true,
canAdmin: true,
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
organization: { canAdd: true, canEdit: true, canDelete: true },
},
},
});
// Convert v2 -> v1
const v1Output = ResponseTransformers.ensureV1Response(v2Dashboard);
const outputPanel = v1Output.dashboard.panels?.[0];
expect(outputPanel).toBeDefined();
// Panel datasource should be preserved
expect(outputPanel?.datasource).toEqual({
uid: 'prometheus-uid',
type: 'prometheus',
});
// First query should not have datasource (matches panel)
expect(outputPanel?.targets?.[0].datasource).toBeUndefined();
// Second query should not have datasource (matches panel)
expect(outputPanel?.targets?.[1].datasource).toBeUndefined();
// Query content should be preserved
expect(outputPanel?.targets?.[0].expr).toBe('up');
expect(outputPanel?.targets?.[1].expr).toBe('down');
});
});
});
});

View File

@@ -40,6 +40,7 @@ import {
defaultDataQueryKind,
RowsLayoutRowKind,
GridLayoutKind,
TabsLayoutTabKind,
defaultDashboardLinkType,
defaultDashboardLink,
defaultFieldConfigSource,
@@ -47,6 +48,7 @@ import {
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { DashboardLink, DataTransformerConfig } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen';
import { isWeekStart, WeekStart } from '@grafana/ui';
import { GRID_COLUMN_COUNT, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import {
AnnoKeyCreatedBy,
AnnoKeyDashboardGnetId,
@@ -488,8 +490,8 @@ export function getDefaultDatasource(): DataSourceRef {
defaultDataSourceRef.apiVersion = dsInstance.apiVersion ?? undefined;
}
// Return only uid and type for panel datasource (apiVersion is not part of DataSourceRef in v1)
return {
apiVersion: defaultDataSourceRef.apiVersion,
type: defaultDataSourceRef.type,
uid: defaultDataSourceRef.uid,
};
@@ -1012,19 +1014,15 @@ function getPanelsV1(
layout: DashboardV2Spec['layout']
): Array<Panel | LibraryPanelDTO> {
const panelsV1: Array<Panel | LibraryPanelDTO | RowPanel> = [];
let maxPanelId = 0;
if (layout.kind !== 'GridLayout') {
throw new Error('Cannot convert non-GridLayout layout to v1');
}
// Flatten nested layouts and convert to v1 panels
const flattenedPanels = flattenLayoutToV1Panels(panels, layout, 0);
for (const item of layout.spec.items) {
const panel = panels[item.spec.element.name];
const v1Panel = transformV2PanelToV1Panel(panel, item);
panelsV1.push(v1Panel);
if (v1Panel.id ?? 0 > maxPanelId) {
maxPanelId = v1Panel.id ?? 0;
for (const panel of flattenedPanels) {
panelsV1.push(panel);
if (panel.id ?? 0 > maxPanelId) {
maxPanelId = panel.id ?? 0;
}
}
@@ -1037,6 +1035,363 @@ function getPanelsV1(
return panelsV1;
}
/**
* Converts AutoGridLayout rowHeight to pixels
* Based on getNamedHeightInPixels from AutoGridLayoutManager
*/
function getRowHeightInPixels(rowHeightMode: 'short' | 'standard' | 'tall' | 'custom', rowHeight?: number): number {
if (rowHeightMode === 'custom' && rowHeight !== undefined) {
return rowHeight;
}
switch (rowHeightMode) {
case 'short':
return 168;
case 'tall':
return 512;
case 'standard':
default:
return 320;
}
}
/**
* Converts pixel height to grid height units
* Based on getGridHeight from DashboardMigrator
*/
function convertPixelsToGridHeight(pixels: number): number {
return Math.ceil(pixels / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN));
}
/**
* Flattens a GridLayout to V1 panels
*/
export function convertGridLayoutToV1Panels(
elements: DashboardV2Spec['elements'],
layout: GridLayoutKind,
baseY: number
): Array<Panel | LibraryPanelDTO | RowPanel> {
const result: Array<Panel | LibraryPanelDTO | RowPanel> = [];
for (const item of layout.spec.items) {
const element = elements[item.spec.element.name];
if (element) {
const v1Panel = transformV2PanelToV1Panel(element, item, baseY);
result.push(v1Panel);
}
}
return result;
}
/**
* Flattens a RowsLayout to V1 panels
* Returns panels and the updated baseY value
*/
export function flattenRowsLayoutToV1Panels(
elements: DashboardV2Spec['elements'],
layout: Extract<DashboardV2Spec['layout'], { kind: 'RowsLayout' }>,
baseY: number
): { panels: Array<Panel | LibraryPanelDTO | RowPanel>; nextBaseY: number } {
const result: Array<Panel | LibraryPanelDTO | RowPanel> = [];
for (let rowIndex = 0; rowIndex < layout.spec.rows.length; rowIndex++) {
const row = layout.spec.rows[rowIndex];
// Special case: Only the first row with hideHeader: true and empty title represents panels that existed
// outside of a row in v1. These should not be converted to RowPanels, but their panels
// should be extracted directly.
const isHiddenHeaderRow =
rowIndex === 0 && row.spec.hideHeader === true && (!row.spec.title || row.spec.title === '');
if (isHiddenHeaderRow) {
// Extract panels directly without creating a RowPanel
// For hidden header rows, panels use absolute Y positions (not relative to baseY)
// since they represent panels that existed outside of a row in v1.
// The Y positions in the grid layout are already absolute (calculated with legacyRowY = -1)
const nestedLayout = row.spec.layout;
// For GridLayout inside hidden header row, extract panels with their absolute Y positions
if (nestedLayout.kind === 'GridLayout') {
for (const item of nestedLayout.spec.items) {
const element = elements[item.spec.element.name];
if (element) {
// Use undefined for yOverride to use the Y position from the grid item directly
const v1Panel = transformV2PanelToV1Panel(element, item, undefined);
result.push(v1Panel);
}
}
} else {
// For other layout types, flatten recursively
const flattenedPanels = flattenLayoutToV1Panels(elements, nestedLayout, 0);
for (const panel of flattenedPanels) {
if (panel.type === 'row' && 'panels' in panel) {
result.push(panel);
} else if ('gridPos' in panel) {
result.push(panel);
}
}
}
// Update baseY based on the panels we just added
const maxY = Math.max(
...result
.filter((p) => 'gridPos' in p && p.type !== 'row')
.map((p) => (p.gridPos?.y ?? 0) + (p.gridPos?.h ?? 0)),
baseY
);
baseY = maxY;
} else {
const { rowPanel, nestedRows, extractedPanels } = convertRowsLayoutRowToV1(elements, row, baseY);
result.push(rowPanel);
// Add nested rows to result (they will be at the top level)
result.push(...nestedRows);
// For expanded rows, panels should be at the top level, not in row.panels array
if (!rowPanel.collapsed && extractedPanels.length > 0) {
result.push(...extractedPanels);
// Calculate next Y position: row height (1) + max panel Y in extracted panels
const maxPanelY = Math.max(...extractedPanels.map((p) => (p.gridPos?.y ?? 0) + (p.gridPos?.h ?? 0)));
baseY = Math.max(baseY + 1, maxPanelY);
} else {
// For collapsed rows, panels are in row.panels array, so just increment by row height
baseY += 1;
}
}
}
return { panels: result, nextBaseY: baseY };
}
/**
* Flattens a TabsLayout to V1 panels
* Returns panels and the updated baseY value
*/
export function flattenTabsLayoutToV1Panels(
elements: DashboardV2Spec['elements'],
layout: Extract<DashboardV2Spec['layout'], { kind: 'TabsLayout' }>,
baseY: number
): { panels: Array<Panel | LibraryPanelDTO | RowPanel>; nextBaseY: number } {
const result: Array<Panel | LibraryPanelDTO | RowPanel> = [];
for (const tab of layout.spec.tabs) {
const { rowPanel, nestedRows, extractedPanels } = convertTabToV1(elements, tab, baseY);
result.push(rowPanel);
// Add nested rows to result (they will be at the top level)
result.push(...nestedRows);
// Tabs are converted to expanded rows, so panels should be at the top level
if (extractedPanels.length > 0) {
result.push(...extractedPanels);
// Calculate next Y position: row height (1) + max panel Y in extracted panels
const maxPanelY = Math.max(...extractedPanels.map((p) => (p.gridPos?.y ?? 0) + (p.gridPos?.h ?? 0)));
baseY = Math.max(baseY + 1, maxPanelY);
} else {
// No panels, just increment by row height
baseY += 1;
}
}
return { panels: result, nextBaseY: baseY };
}
/**
* Converts an AutoGridLayout to V1 panels
*/
export function convertAutoGridLayoutToV1Panels(
elements: DashboardV2Spec['elements'],
layout: Extract<DashboardV2Spec['layout'], { kind: 'AutoGridLayout' }>,
baseY: number
): Array<Panel | LibraryPanelDTO | RowPanel> {
const result: Array<Panel | LibraryPanelDTO | RowPanel> = [];
const maxColumnCount = layout.spec.maxColumnCount ?? 3;
const panelWidth = Math.floor(GRID_COLUMN_COUNT / maxColumnCount);
// Convert rowHeight to grid height
const rowHeightMode = layout.spec.rowHeightMode ?? 'standard';
const rowHeightPixels = getRowHeightInPixels(rowHeightMode, layout.spec.rowHeight);
const panelHeight = convertPixelsToGridHeight(rowHeightPixels);
let currentX = 0;
let currentY = baseY;
for (const item of layout.spec.items) {
const element = elements[item.spec.element.name];
if (element) {
// Create a GridLayoutItemKind-like structure for auto grid items
const gridItem: GridLayoutItemKind = {
kind: 'GridLayoutItem',
spec: {
x: currentX,
y: currentY,
width: panelWidth,
height: panelHeight,
element: item.spec.element,
...(item.spec.repeat && { repeat: item.spec.repeat }),
},
};
const v1Panel = transformV2PanelToV1Panel(element, gridItem);
result.push(v1Panel);
// Move to next position
currentX += panelWidth;
if (currentX + panelWidth > GRID_COLUMN_COUNT) {
currentX = 0;
currentY += panelHeight;
}
}
}
return result;
}
function flattenLayoutToV1Panels(
elements: DashboardV2Spec['elements'],
layout: DashboardV2Spec['layout'],
baseY: number
): Array<Panel | LibraryPanelDTO | RowPanel> {
switch (layout.kind) {
case 'GridLayout':
return convertGridLayoutToV1Panels(elements, layout, baseY);
case 'RowsLayout': {
const { panels } = flattenRowsLayoutToV1Panels(elements, layout, baseY);
return panels;
}
case 'TabsLayout': {
const { panels } = flattenTabsLayoutToV1Panels(elements, layout, baseY);
return panels;
}
case 'AutoGridLayout':
return convertAutoGridLayoutToV1Panels(elements, layout, baseY);
}
}
function convertRowsLayoutRowToV1(
elements: DashboardV2Spec['elements'],
row: RowsLayoutRowKind,
baseY: number
): { rowPanel: RowPanel; nestedRows: RowPanel[]; extractedPanels: Panel[] } {
const rowPanels: Panel[] = [];
const nestedRows: RowPanel[] = [];
const extractedPanels: Panel[] = [];
const nestedLayout = row.spec.layout;
const isCollapsed = row.spec.collapse ?? false;
// Panels in v2 have Y positions relative to the row, we need to convert them to absolute
// by processing the grid layout directly and converting relative Y to absolute Y
if (nestedLayout.kind === 'GridLayout') {
// For GridLayout, convert relative Y positions to absolute
for (const item of nestedLayout.spec.items) {
const element = elements[item.spec.element.name];
if (element) {
// Convert relative Y to absolute: rowY + rowHeaderHeight + relativeY
const absoluteY = baseY + GRID_ROW_HEIGHT + item.spec.y;
const v1Panel = transformV2PanelToV1Panel(element, item, absoluteY);
if (v1Panel.type !== 'row') {
if (isCollapsed) {
// For collapsed rows, panels go in row.panels array
rowPanels.push(v1Panel);
} else {
// For expanded rows, panels go to top level
extractedPanels.push(v1Panel);
}
}
}
}
} else {
// For other layout types, flatten recursively
const flattenedPanels = flattenLayoutToV1Panels(elements, nestedLayout, baseY + GRID_ROW_HEIGHT);
for (const panel of flattenedPanels) {
if (panel.type === 'row' && 'panels' in panel) {
// This is a nested row (could be a tab converted to a row)
nestedRows.push(panel);
} else if ('gridPos' in panel && panel.type !== 'row') {
if (isCollapsed) {
// For collapsed rows, panels go in row.panels array
rowPanels.push(panel);
} else {
// For expanded rows, panels go to top level
extractedPanels.push(panel);
}
}
}
}
const rowPanel: RowPanel = {
id: -1, // Will be assigned later
type: 'row',
title: row.spec.title ?? '',
collapsed: isCollapsed,
gridPos: {
x: 0,
y: baseY,
w: GRID_COLUMN_COUNT,
h: 1,
},
panels: rowPanels, // Only populated for collapsed rows
...(row.spec.repeat && { repeat: row.spec.repeat.value }),
};
return { rowPanel, nestedRows, extractedPanels };
}
function convertTabToV1(
elements: DashboardV2Spec['elements'],
tab: TabsLayoutTabKind,
baseY: number
): { rowPanel: RowPanel; nestedRows: RowPanel[]; extractedPanels: Panel[] } {
const nestedRows: RowPanel[] = [];
const extractedPanels: Panel[] = [];
const nestedLayout = tab.spec.layout;
// Panels in v2 have Y positions relative to the row, we need to convert them to absolute
// by processing the grid layout directly and converting relative Y to absolute Y
// Tabs are converted to expanded rows, so panels go to top level (extractedPanels)
if (nestedLayout.kind === 'GridLayout') {
// For GridLayout, convert relative Y positions to absolute
for (const item of nestedLayout.spec.items) {
const element = elements[item.spec.element.name];
if (element) {
// Convert relative Y to absolute: rowY + rowHeaderHeight + relativeY
const absoluteY = baseY + GRID_ROW_HEIGHT + item.spec.y;
const v1Panel = transformV2PanelToV1Panel(element, item, absoluteY);
if (v1Panel.type !== 'row') {
// Tabs are expanded rows, so panels go to top level
extractedPanels.push(v1Panel);
}
}
}
} else {
// For other layout types, flatten recursively
const flattenedPanels = flattenLayoutToV1Panels(elements, nestedLayout, baseY + GRID_ROW_HEIGHT);
for (const panel of flattenedPanels) {
if (panel.type === 'row' && 'panels' in panel) {
nestedRows.push(panel);
} else if ('gridPos' in panel && panel.type !== 'row') {
// Tabs are expanded rows, so panels go to top level
extractedPanels.push(panel);
}
}
}
const rowPanel: RowPanel = {
id: -1, // Will be assigned later
type: 'row',
title: tab.spec.title ?? '',
collapsed: false, // Tabs are always expanded in v1 (as rows)
gridPos: {
x: 0,
y: baseY,
w: GRID_COLUMN_COUNT,
h: 1,
},
panels: [], // Tabs are expanded rows, so panels array is empty
...(tab.spec.repeat && { repeat: tab.spec.repeat.value }),
};
return { rowPanel, nestedRows, extractedPanels };
}
function transformV2PanelToV1Panel(
p: PanelKind | LibraryPanelKind,
layoutElement: GridLayoutItemKind,
@@ -1046,6 +1401,85 @@ function transformV2PanelToV1Panel(
const gridPos = { x, y: yOverride ?? y, w: width, h: height };
if (p.kind === 'Panel') {
const panel = p.spec;
// Extract datasources from queries and reconstruct panel-level datasource
// In v2beta1: datasource UID is at q.spec.query.datasource?.name, type is at q.spec.query.group
const queryDatasources = panel.data.spec.queries.map((q) => {
const dsUid = q.spec.query.datasource?.name;
const dsType = q.spec.query.group || '';
return { uid: dsUid, type: dsType };
});
// Determine panel-level datasource
// If all queries share the same datasource, use it as panel datasource
// If queries have different datasources, use mixed datasource
// Compare by UID first (UID is the primary identifier), then by type if UID is missing
// If there are no queries, don't set a panel datasource (e.g., text panels)
let panelDatasource: DataSourceRef | undefined;
if (queryDatasources.length === 0) {
// No queries - don't set a panel datasource (e.g., text panels)
panelDatasource = undefined;
} else {
const uniqueDatasources = new Set(
queryDatasources.map((ds) => {
// Use UID as primary identifier, fallback to type if UID is missing
return ds.uid || ds.type || '';
})
);
if (uniqueDatasources.size === 1 && uniqueDatasources.has('')) {
// All queries have no datasource - use default
panelDatasource = getDefaultDatasource();
} else if (uniqueDatasources.size === 1) {
// All queries share the same datasource - use it as panel datasource
const firstDs = queryDatasources[0];
if (firstDs.uid || firstDs.type) {
panelDatasource = {
uid: firstDs.uid || undefined,
type: firstDs.type || undefined,
};
}
} else if (uniqueDatasources.size > 1) {
// Multiple different datasources - use mixed datasource
panelDatasource = {
uid: '-- Mixed --',
type: undefined,
};
} else {
// No datasources found - use default
panelDatasource = getDefaultDatasource();
}
}
// Build targets with proper datasource handling
// Only include datasource on query if it differs from panel datasource
const targets = panel.data.spec.queries.map((q, index) => {
const queryDs = queryDatasources[index];
// Determine if this query's datasource differs from panel datasource
// For mixed datasources, always include query datasource
// Otherwise, compare by UID (primary) or type (if UID missing)
const queryDiffers =
panelDatasource?.uid === '-- Mixed --' ||
(queryDs.uid && queryDs.uid !== panelDatasource?.uid) ||
(!queryDs.uid && queryDs.type && queryDs.type !== panelDatasource?.type);
const queryDsRef: DataSourceRef | undefined = queryDiffers
? {
uid: queryDs.uid || undefined,
type: queryDs.type || undefined,
}
: undefined;
return {
refId: q.spec.refId,
hide: q.spec.hidden,
...(queryDsRef && { datasource: queryDsRef }),
...q.spec.query.spec,
};
});
return {
id: panel.id,
type: panel.vizConfig.group,
@@ -1054,6 +1488,7 @@ function transformV2PanelToV1Panel(
fieldConfig: transformMappingsToV1(panel.vizConfig.spec.fieldConfig),
options: panel.vizConfig.spec.options,
pluginVersion: panel.vizConfig.version,
...(panelDatasource && { datasource: panelDatasource }),
links:
// @ts-expect-error - Panel link is wrongly typed as DashboardLink
panel.links?.map<DashboardLink>((l) => ({
@@ -1061,17 +1496,7 @@ function transformV2PanelToV1Panel(
url: l.url,
...(l.targetBlank !== undefined && { targetBlank: l.targetBlank }),
})) || [],
targets: panel.data.spec.queries.map((q) => {
return {
refId: q.spec.refId,
hide: q.spec.hidden,
datasource: {
uid: q.spec.query.spec.datasource?.uid,
type: q.spec.query.spec.group,
},
...q.spec.query.spec,
};
}),
targets,
transformations: panel.data.spec.transformations.map((t) => t.spec),
gridPos,
...(panel.data.spec.queryOptions.cacheTimeout !== undefined && {
@@ -1125,7 +1550,7 @@ export function transformMappingsToV1(fieldConfig: FieldConfigSource): FieldConf
}
};
const transformedDefaults: any = {
const transformedDefaults: Record<string, unknown> = {
...fieldConfig.defaults,
};

File diff suppressed because it is too large Load Diff

View File

@@ -58,7 +58,7 @@ describe('UnifiedDashboardAPI', () => {
const result = await api.getDashboardDTO('123');
expect(result).toBe(mockResponse);
expect(v1Client.getDashboardDTO).toHaveBeenCalledWith('123');
expect(v1Client.getDashboardDTO).toHaveBeenCalledWith('123', undefined);
expect(v2Client.getDashboardDTO).not.toHaveBeenCalled();
});

View File

@@ -1,3 +1,4 @@
import { UrlQueryMap } from '@grafana/data';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { isResource } from 'app/features/apiserver/guards';
@@ -6,6 +7,7 @@ import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { ResponseTransformers } from './ResponseTransformers';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types';
import {
failedFromVersion,
@@ -29,12 +31,15 @@ export class UnifiedDashboardAPI
}
// Get operation depends on the dashboard format to use one of the two clients
async getDashboardDTO(uid: string) {
async getDashboardDTO(uid: string, params?: UrlQueryMap) {
try {
return await this.v1Client.getDashboardDTO(uid);
return await this.v1Client.getDashboardDTO(uid, params);
} catch (error) {
if (error instanceof DashboardVersionError && isV2StoredVersion(error.data.storedVersion)) {
return await this.v2Client.getDashboardDTO(uid);
// If v1 API failed due to v2 dashboard, try loading as v2 and converting to v1 on frontend
// This allows frontend conversion to be tested even when scenes are enabled
const v2Dash = await this.v2Client.getDashboardDTO(uid);
return ResponseTransformers.ensureV1Response(v2Dash);
}
throw error;
}

View File

@@ -25,6 +25,8 @@ import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/ty
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { ResponseTransformers } from './ResponseTransformers';
import { getDashboardAPI } from './dashboard_api';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types';
import { isV2StoredVersion } from './utils';
@@ -125,7 +127,14 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
// This could come as conversion error from v0 or v2 to V1.
if (dash.status?.conversion?.failed && isV2StoredVersion(dash.status.conversion.storedVersion)) {
throw new DashboardVersionError(dash.status.conversion.storedVersion, dash.status.conversion.error);
// Always try frontend conversion as fallback when backend conversion fails for v2 dashboards
try {
const v2Dash = await getDashboardAPI('v2').getDashboardDTO(uid, params);
return ResponseTransformers.ensureV1Response(v2Dash);
} catch (e) {
// If v2 load fails, throw the original conversion error
throw new DashboardVersionError(dash.status.conversion.storedVersion, dash.status.conversion.error);
}
}
const result: DashboardDTO = {

View File

@@ -16,6 +16,7 @@ import { appEvents } from '../../../core/app_events';
import { ResponseTransformers } from '../api/ResponseTransformers';
import { getDashboardAPI } from '../api/dashboard_api';
import { DashboardVersionError, DashboardWithAccessInfo } from '../api/types';
import { isV2StoredVersion } from '../api/utils';
import { getDashboardSrv } from './DashboardSrv';
import { getDashboardSnapshotSrv } from './SnapshotSrv';
@@ -144,6 +145,15 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
return result;
})
.catch((e) => {
// If backend conversion failed for a v2 dashboard, try frontend conversion
if (e instanceof DashboardVersionError && isV2StoredVersion(e.data.storedVersion)) {
// Try loading as v2 and converting on frontend
return getDashboardAPI('v2')
.getDashboardDTO(uid, params)
.then((v2Result) => {
return ResponseTransformers.ensureV1Response(v2Result);
});
}
if (isFetchError(e) && !(e instanceof DashboardVersionError)) {
console.error('Failed to load dashboard', e);
e.isHandled = true;