mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 03:54:29 +08:00
Compare commits
11 Commits
docs/add-t
...
oscark/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba29e8f6ba | ||
|
|
16dcaa9f58 | ||
|
|
bf29c235f8 | ||
|
|
ee95eacbb8 | ||
|
|
6bb51bfb17 | ||
|
|
22ed894bce | ||
|
|
559483c027 | ||
|
|
036a7fb16f | ||
|
|
f22172c6b4 | ||
|
|
851a83b8e9 | ||
|
|
441a9c7771 |
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
1699
public/app/features/dashboard/api/ResponseTransformersLayout.test.ts
Normal file
1699
public/app/features/dashboard/api/ResponseTransformersLayout.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user