mirror of https://github.com/grafana/grafana
Dashboard API versions handling (#96666)
* structure apic * API versioning proposal * Make api service independent from version * Update v2 * Fix public dashboards page test * Uncomment reload dashboard feature code * Revert * Betterer * Fix imports * useV2DashboardsAPI feature toggle * POC/v2 schema: Add v1<-> v2 transformers (#97058) * Make dshboard access interface more precise * Add first pass for schema v1<->v2 transformers * Update response transformer test * Import fixes * Manage dashboards validation: Handle v2 schema * Handle new dashboard with v2 * Fix tests * Move dashboard is folder error handling to legacy API implementation * Add tests for dashboard api client * betterer * Use dashboard DTO when capturing dashbaord impression * prettier * Dashboard API: resolve folder metadata * Add tests for resolving folder metadata * Fix DashboardPicker * Renames and nits * POC Alternative Suggestion for Dashboard API versions handling (#97789) * Add transitional_dashboard_api, reset components that are not ready for v2 schema, and start working on migrating DashboardPicker to use v2 schema * reset DashboardScenePageStateManager * Improve logic in transitional api, also remove isDashboardResource checks from components * REmove transitional_dashboard_api and apply PR feedback * Apply PR feedback, use 'v2' as a parameter and remove unnecesary if * Fix tests * Adding missing comments from original PR and also changing order to improve diffing in github :) * update betterer * fix prettier * Add tests for DashboardPicker * Do not use unified alerting mocks * Fix unused type in dashboard test * Improve comments in DahboardPicker * Update folder validation fn * Validation update * Update legacy api test * Lint --------- Co-authored-by: alexandra vargas <alexa1866@gmail.com> Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com> Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>pull/98151/head
parent
31e67b5ba3
commit
070f0e4457
|
@ -0,0 +1,135 @@ |
||||
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/dashboard.gen'; |
||||
import { backendSrv } from 'app/core/services/backend_srv'; |
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; |
||||
import { DashboardSearchItemType } from 'app/features/search/types'; |
||||
import { DashboardDTO } from 'app/types'; |
||||
|
||||
import { DashboardPicker } from './DashboardPicker'; |
||||
|
||||
jest.mock('app/core/services/backend_srv', () => ({ |
||||
...jest.requireActual('app/core/services/backend_srv'), |
||||
backendSrv: { |
||||
...jest.requireActual('app/core/services/backend_srv').backendSrv, |
||||
search: jest.fn(), |
||||
}, |
||||
})); |
||||
|
||||
const getDashboardDTO = jest.fn(); |
||||
|
||||
jest.mock('app/features/dashboard/api/dashboard_api', () => ({ |
||||
getDashboardAPI: () => ({ |
||||
getDashboardDTO: getDashboardDTO, |
||||
}), |
||||
})); |
||||
|
||||
jest.mock('react-virtualized-auto-sizer', () => { |
||||
return ({ children }: Props) => |
||||
children({ |
||||
height: 600, |
||||
scaledHeight: 600, |
||||
scaledWidth: 1, |
||||
width: 1, |
||||
}); |
||||
}); |
||||
|
||||
jest.mocked(backendSrv.search).mockResolvedValue([ |
||||
{ |
||||
uid: 'dash-1', |
||||
type: DashboardSearchItemType.DashDB, |
||||
title: 'Dashboard 1', |
||||
uri: '', |
||||
url: '', |
||||
tags: [], |
||||
isStarred: false, |
||||
}, |
||||
{ |
||||
uid: 'dash-2', |
||||
type: DashboardSearchItemType.DashDB, |
||||
title: 'Dashboard 2', |
||||
uri: '', |
||||
url: '', |
||||
tags: [], |
||||
isStarred: false, |
||||
}, |
||||
{ |
||||
uid: 'dash-3', |
||||
type: DashboardSearchItemType.DashDB, |
||||
title: 'Dashboard 3', |
||||
uri: '', |
||||
url: '', |
||||
tags: [], |
||||
isStarred: false, |
||||
}, |
||||
]); |
||||
|
||||
const mockDashboard: DashboardDTO = { |
||||
dashboard: { |
||||
...defaultDashboardData, |
||||
uid: 'dash-2', |
||||
title: 'Dashboard 2', |
||||
}, |
||||
meta: {}, |
||||
}; |
||||
|
||||
const mockDashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = { |
||||
apiVersion: 'v2alpha0', |
||||
kind: 'DashboardWithAccessInfo', |
||||
spec: { |
||||
...defaultDashboardV2Spec(), |
||||
title: 'Dashboard 2', |
||||
}, |
||||
metadata: { |
||||
name: 'dash-2', |
||||
resourceVersion: '0', |
||||
creationTimestamp: '0', |
||||
annotations: {}, |
||||
}, |
||||
access: { |
||||
canEdit: true, |
||||
canSave: true, |
||||
canStar: true, |
||||
canShare: true, |
||||
}, |
||||
}; |
||||
|
||||
describe('DashboardPicker', () => { |
||||
describe.each([ |
||||
['v1', mockDashboard], |
||||
['v2', mockDashboardV2], |
||||
])('Dashboard %s', (format, dashboard) => { |
||||
beforeEach(() => { |
||||
config.featureToggles.useV2DashboardsAPI = format === 'v2'; |
||||
getDashboardDTO.mockResolvedValue(dashboard); |
||||
}); |
||||
|
||||
it('should fetch and display dashboards', async () => { |
||||
render(<DashboardPicker value="dash-2" onChange={noop} />); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument(); |
||||
expect(getDashboardDTO).toHaveBeenCalledWith('dash-2', undefined); |
||||
}); |
||||
}); |
||||
|
||||
it('should search for dashboards', async () => { |
||||
render(<DashboardPicker onChange={noop} />); |
||||
|
||||
await userEvent.type(screen.getByRole('combobox'), 'Dashboard 2'); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
expect(backendSrv.search).toHaveBeenCalledWith({ type: 'dash-db', query: 'Dashboard 2', limit: 100 }); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,216 @@ |
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; |
||||
import { DashboardDTO } from 'app/types'; |
||||
|
||||
import { ResponseTransformers } from './ResponseTransformers'; |
||||
import { DashboardWithAccessInfo } from './types'; |
||||
|
||||
describe('ResponseTransformers', () => { |
||||
describe('v1 transformation', () => { |
||||
it('should transform DashboardDTO to DashboardWithAccessInfo<DashboardV2Spec>', () => { |
||||
const dashboardDTO: DashboardDTO = { |
||||
meta: { |
||||
created: '2023-01-01T00:00:00Z', |
||||
createdBy: 'user1', |
||||
updated: '2023-01-02T00:00:00Z', |
||||
updatedBy: 'user2', |
||||
folderUid: 'folder1', |
||||
slug: 'dashboard-slug', |
||||
url: '/d/dashboard-slug', |
||||
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 }, |
||||
}, |
||||
}, |
||||
dashboard: { |
||||
uid: 'dashboard1', |
||||
title: 'Dashboard Title', |
||||
description: 'Dashboard Description', |
||||
tags: ['tag1', 'tag2'], |
||||
schemaVersion: 1, |
||||
graphTooltip: 0, |
||||
preload: true, |
||||
liveNow: false, |
||||
editable: true, |
||||
time: { from: 'now-6h', to: 'now' }, |
||||
timezone: 'browser', |
||||
refresh: '5m', |
||||
timepicker: { |
||||
refresh_intervals: ['5s', '10s', '30s'], |
||||
hidden: false, |
||||
time_options: ['5m', '15m', '1h'], |
||||
nowDelay: '1m', |
||||
}, |
||||
fiscalYearStartMonth: 1, |
||||
weekStart: 'monday', |
||||
version: 1, |
||||
links: [], |
||||
annotations: { |
||||
list: [], |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const transformed = ResponseTransformers.ensureV2Response(dashboardDTO); |
||||
|
||||
expect(transformed.apiVersion).toBe('v2alpha1'); |
||||
expect(transformed.kind).toBe('DashboardWithAccessInfo'); |
||||
expect(transformed.metadata.creationTimestamp).toBe(dashboardDTO.meta.created); |
||||
expect(transformed.metadata.name).toBe(dashboardDTO.dashboard.uid); |
||||
expect(transformed.metadata.resourceVersion).toBe(dashboardDTO.dashboard.version?.toString()); |
||||
expect(transformed.metadata.annotations?.['grafana.app/createdBy']).toBe(dashboardDTO.meta.createdBy); |
||||
expect(transformed.metadata.annotations?.['grafana.app/updatedBy']).toBe(dashboardDTO.meta.updatedBy); |
||||
expect(transformed.metadata.annotations?.['grafana.app/updatedTimestamp']).toBe(dashboardDTO.meta.updated); |
||||
expect(transformed.metadata.annotations?.['grafana.app/folder']).toBe(dashboardDTO.meta.folderUid); |
||||
expect(transformed.metadata.annotations?.['grafana.app/slug']).toBe(dashboardDTO.meta.slug); |
||||
|
||||
const spec = transformed.spec; |
||||
expect(spec.title).toBe(dashboardDTO.dashboard.title); |
||||
expect(spec.description).toBe(dashboardDTO.dashboard.description); |
||||
expect(spec.tags).toEqual(dashboardDTO.dashboard.tags); |
||||
expect(spec.schemaVersion).toBe(dashboardDTO.dashboard.schemaVersion); |
||||
expect(spec.cursorSync).toBe('Off'); // Assuming transformCursorSynctoEnum(0) returns 'Off'
|
||||
expect(spec.preload).toBe(dashboardDTO.dashboard.preload); |
||||
expect(spec.liveNow).toBe(dashboardDTO.dashboard.liveNow); |
||||
expect(spec.editable).toBe(dashboardDTO.dashboard.editable); |
||||
expect(spec.timeSettings.from).toBe(dashboardDTO.dashboard.time?.from); |
||||
expect(spec.timeSettings.to).toBe(dashboardDTO.dashboard.time?.to); |
||||
expect(spec.timeSettings.timezone).toBe(dashboardDTO.dashboard.timezone); |
||||
expect(spec.timeSettings.autoRefresh).toBe(dashboardDTO.dashboard.refresh); |
||||
expect(spec.timeSettings.autoRefreshIntervals).toEqual(dashboardDTO.dashboard.timepicker?.refresh_intervals); |
||||
expect(spec.timeSettings.hideTimepicker).toBe(dashboardDTO.dashboard.timepicker?.hidden); |
||||
expect(spec.timeSettings.quickRanges).toEqual(dashboardDTO.dashboard.timepicker?.time_options); |
||||
expect(spec.timeSettings.nowDelay).toBe(dashboardDTO.dashboard.timepicker?.nowDelay); |
||||
expect(spec.timeSettings.fiscalYearStartMonth).toBe(dashboardDTO.dashboard.fiscalYearStartMonth); |
||||
expect(spec.timeSettings.weekStart).toBe(dashboardDTO.dashboard.weekStart); |
||||
expect(spec.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns []
|
||||
expect(spec.annotations).toEqual([]); |
||||
}); |
||||
}); |
||||
|
||||
describe('v2 transformation', () => { |
||||
it('should return the same object if it is already a DashboardDTO', () => { |
||||
const dashboard: DashboardDTO = { |
||||
dashboard: { |
||||
schemaVersion: 1, |
||||
title: 'Dashboard Title', |
||||
uid: 'dashboard1', |
||||
version: 1, |
||||
}, |
||||
meta: {}, |
||||
}; |
||||
|
||||
expect(ResponseTransformers.ensureV1Response(dashboard)).toBe(dashboard); |
||||
}); |
||||
|
||||
it('should transform DashboardWithAccessInfo<DashboardV2Spec> to DashboardDTO', () => { |
||||
const dashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = { |
||||
apiVersion: 'v2alpha1', |
||||
kind: 'DashboardWithAccessInfo', |
||||
metadata: { |
||||
creationTimestamp: '2023-01-01T00:00:00Z', |
||||
name: 'dashboard1', |
||||
resourceVersion: '1', |
||||
annotations: { |
||||
'grafana.app/createdBy': 'user1', |
||||
'grafana.app/updatedBy': 'user2', |
||||
'grafana.app/updatedTimestamp': '2023-01-02T00:00:00Z', |
||||
'grafana.app/folder': 'folder1', |
||||
'grafana.app/slug': 'dashboard-slug', |
||||
}, |
||||
}, |
||||
spec: { |
||||
title: 'Dashboard Title', |
||||
description: 'Dashboard Description', |
||||
tags: ['tag1', 'tag2'], |
||||
schemaVersion: 1, |
||||
cursorSync: 'Off', |
||||
preload: true, |
||||
liveNow: false, |
||||
editable: true, |
||||
timeSettings: { |
||||
from: 'now-6h', |
||||
to: 'now', |
||||
timezone: 'browser', |
||||
autoRefresh: '5m', |
||||
autoRefreshIntervals: ['5s', '10s', '30s'], |
||||
hideTimepicker: false, |
||||
quickRanges: ['5m', '15m', '1h'], |
||||
nowDelay: '1m', |
||||
fiscalYearStartMonth: 1, |
||||
weekStart: 'monday', |
||||
}, |
||||
links: [], |
||||
annotations: [], |
||||
variables: [], |
||||
elements: {}, |
||||
layout: { |
||||
kind: 'GridLayout', |
||||
spec: { |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
access: { |
||||
url: '/d/dashboard-slug', |
||||
canAdmin: true, |
||||
canDelete: true, |
||||
canEdit: true, |
||||
canSave: true, |
||||
canShare: true, |
||||
canStar: true, |
||||
slug: 'dashboard-slug', |
||||
annotationsPermissions: { |
||||
dashboard: { canAdd: true, canEdit: true, canDelete: true }, |
||||
organization: { canAdd: true, canEdit: true, canDelete: true }, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const transformed = ResponseTransformers.ensureV1Response(dashboardV2); |
||||
|
||||
expect(transformed.meta.created).toBe(dashboardV2.metadata.creationTimestamp); |
||||
expect(transformed.meta.createdBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/createdBy']); |
||||
expect(transformed.meta.updated).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedTimestamp']); |
||||
expect(transformed.meta.updatedBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedBy']); |
||||
expect(transformed.meta.folderUid).toBe(dashboardV2.metadata.annotations?.['grafana.app/folder']); |
||||
expect(transformed.meta.slug).toBe(dashboardV2.metadata.annotations?.['grafana.app/slug']); |
||||
expect(transformed.meta.url).toBe(dashboardV2.access.url); |
||||
expect(transformed.meta.canAdmin).toBe(dashboardV2.access.canAdmin); |
||||
expect(transformed.meta.canDelete).toBe(dashboardV2.access.canDelete); |
||||
expect(transformed.meta.canEdit).toBe(dashboardV2.access.canEdit); |
||||
expect(transformed.meta.canSave).toBe(dashboardV2.access.canSave); |
||||
expect(transformed.meta.canShare).toBe(dashboardV2.access.canShare); |
||||
expect(transformed.meta.canStar).toBe(dashboardV2.access.canStar); |
||||
expect(transformed.meta.annotationsPermissions).toEqual(dashboardV2.access.annotationsPermissions); |
||||
|
||||
const dashboard = transformed.dashboard; |
||||
expect(dashboard.uid).toBe(dashboardV2.metadata.name); |
||||
expect(dashboard.title).toBe(dashboardV2.spec.title); |
||||
expect(dashboard.description).toBe(dashboardV2.spec.description); |
||||
expect(dashboard.tags).toEqual(dashboardV2.spec.tags); |
||||
expect(dashboard.schemaVersion).toBe(dashboardV2.spec.schemaVersion); |
||||
// expect(dashboard.graphTooltip).toBe(0); // Assuming transformCursorSynctoEnum('Off') returns 0
|
||||
expect(dashboard.preload).toBe(dashboardV2.spec.preload); |
||||
expect(dashboard.liveNow).toBe(dashboardV2.spec.liveNow); |
||||
expect(dashboard.editable).toBe(dashboardV2.spec.editable); |
||||
expect(dashboard.time?.from).toBe(dashboardV2.spec.timeSettings.from); |
||||
expect(dashboard.time?.to).toBe(dashboardV2.spec.timeSettings.to); |
||||
expect(dashboard.timezone).toBe(dashboardV2.spec.timeSettings.timezone); |
||||
expect(dashboard.refresh).toBe(dashboardV2.spec.timeSettings.autoRefresh); |
||||
expect(dashboard.timepicker?.refresh_intervals).toEqual(dashboardV2.spec.timeSettings.autoRefreshIntervals); |
||||
expect(dashboard.timepicker?.hidden).toBe(dashboardV2.spec.timeSettings.hideTimepicker); |
||||
expect(dashboard.timepicker?.time_options).toEqual(dashboardV2.spec.timeSettings.quickRanges); |
||||
expect(dashboard.timepicker?.nowDelay).toBe(dashboardV2.spec.timeSettings.nowDelay); |
||||
expect(dashboard.fiscalYearStartMonth).toBe(dashboardV2.spec.timeSettings.fiscalYearStartMonth); |
||||
expect(dashboard.weekStart).toBe(dashboardV2.spec.timeSettings.weekStart); |
||||
expect(dashboard.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns []
|
||||
expect(dashboard.annotations).toEqual({ list: [] }); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,179 @@ |
||||
import { |
||||
DashboardV2Spec, |
||||
defaultDashboardV2Spec, |
||||
defaultTimeSettingsSpec, |
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; |
||||
import { transformCursorSynctoEnum } from 'app/features/dashboard-scene/serialization/transformToV2TypesUtils'; |
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types'; |
||||
|
||||
import { DashboardWithAccessInfo } from './types'; |
||||
import { isDashboardResource, isDashboardV0Spec, isDashboardV2Spec } from './utils'; |
||||
|
||||
export function ensureV2Response( |
||||
dto: DashboardDTO | DashboardWithAccessInfo<DashboardDataDTO> | DashboardWithAccessInfo<DashboardV2Spec> |
||||
): DashboardWithAccessInfo<DashboardV2Spec> { |
||||
if (isDashboardResource(dto) && isDashboardV2Spec(dto.spec)) { |
||||
return dto as DashboardWithAccessInfo<DashboardV2Spec>; |
||||
} |
||||
|
||||
// after discarding the dto is not a v2 spec, we can safely assume it's a v0 spec or a dashboardDTO
|
||||
dto = dto as unknown as DashboardWithAccessInfo<DashboardDataDTO> | DashboardDTO; |
||||
|
||||
const timeSettingsDefaults = defaultTimeSettingsSpec(); |
||||
const dashboardDefaults = defaultDashboardV2Spec(); |
||||
|
||||
const dashboard = isDashboardResource(dto) ? dto.spec : dto.dashboard; |
||||
|
||||
const accessAndMeta = isDashboardResource(dto) |
||||
? { |
||||
...dto.access, |
||||
created: dto.metadata.creationTimestamp, |
||||
createdBy: dto.metadata.annotations?.['grafana.app/createdBy'], |
||||
updatedBy: dto.metadata.annotations?.['grafana.app/updatedBy'], |
||||
updated: dto.metadata.annotations?.['grafana.app/updatedTimestamp'], |
||||
folderUid: dto.metadata.annotations?.['grafana.app/folder'], |
||||
slug: dto.metadata.annotations?.['grafana.app/slug'], |
||||
} |
||||
: dto.meta; |
||||
|
||||
const spec: DashboardV2Spec = { |
||||
title: dashboard.title, |
||||
description: dashboard.description, |
||||
tags: dashboard.tags, |
||||
schemaVersion: dashboard.schemaVersion, |
||||
cursorSync: transformCursorSynctoEnum(dashboard.graphTooltip), |
||||
preload: dashboard.preload || dashboardDefaults.preload, |
||||
liveNow: dashboard.liveNow, |
||||
editable: dashboard.editable, |
||||
timeSettings: { |
||||
from: dashboard.time?.from || timeSettingsDefaults.from, |
||||
to: dashboard.time?.to || timeSettingsDefaults.to, |
||||
timezone: dashboard.timezone || timeSettingsDefaults.timezone, |
||||
autoRefresh: dashboard.refresh || timeSettingsDefaults.autoRefresh, |
||||
autoRefreshIntervals: dashboard.timepicker?.refresh_intervals || timeSettingsDefaults.autoRefreshIntervals, |
||||
fiscalYearStartMonth: dashboard.fiscalYearStartMonth || timeSettingsDefaults.fiscalYearStartMonth, |
||||
hideTimepicker: dashboard.timepicker?.hidden || timeSettingsDefaults.hideTimepicker, |
||||
quickRanges: dashboard.timepicker?.time_options || timeSettingsDefaults.quickRanges, |
||||
weekStart: dashboard.weekStart || timeSettingsDefaults.weekStart, |
||||
nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay, |
||||
}, |
||||
links: dashboard.links || [], |
||||
annotations: [], // TODO
|
||||
variables: [], // todo
|
||||
elements: {}, // todo
|
||||
layout: { |
||||
// todo
|
||||
kind: 'GridLayout', |
||||
spec: { |
||||
items: [], |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
return { |
||||
apiVersion: 'v2alpha1', |
||||
kind: 'DashboardWithAccessInfo', |
||||
metadata: { |
||||
creationTimestamp: accessAndMeta.created || '', // TODO verify this empty string is valid
|
||||
name: dashboard.uid, |
||||
resourceVersion: dashboard.version?.toString() || '0', |
||||
annotations: { |
||||
'grafana.app/createdBy': accessAndMeta.createdBy, |
||||
'grafana.app/updatedBy': accessAndMeta.updatedBy, |
||||
'grafana.app/updatedTimestamp': accessAndMeta.updated, |
||||
'grafana.app/folder': accessAndMeta.folderUid, |
||||
'grafana.app/slug': accessAndMeta.slug, |
||||
}, |
||||
}, |
||||
spec, |
||||
access: { |
||||
url: accessAndMeta.url || '', |
||||
canAdmin: accessAndMeta.canAdmin, |
||||
canDelete: accessAndMeta.canDelete, |
||||
canEdit: accessAndMeta.canEdit, |
||||
canSave: accessAndMeta.canSave, |
||||
canShare: accessAndMeta.canShare, |
||||
canStar: accessAndMeta.canStar, |
||||
slug: accessAndMeta.slug, |
||||
annotationsPermissions: accessAndMeta.annotationsPermissions, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
export function ensureV1Response( |
||||
dashboard: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> |
||||
): DashboardDTO { |
||||
// if dashboard is not on v0 schema or v2 schema, return as is
|
||||
if (!isDashboardResource(dashboard)) { |
||||
return dashboard; |
||||
} |
||||
|
||||
const spec = dashboard.spec; |
||||
// if dashboard is on v0 schema
|
||||
if (isDashboardV0Spec(spec)) { |
||||
return { |
||||
meta: { |
||||
...dashboard.access, |
||||
isNew: false, |
||||
isFolder: false, |
||||
uid: dashboard.metadata.name, |
||||
k8s: dashboard.metadata, |
||||
}, |
||||
dashboard: spec, |
||||
}; |
||||
} else { |
||||
// if dashboard is on v2 schema convert to v1 schema
|
||||
return { |
||||
meta: { |
||||
created: dashboard.metadata.creationTimestamp, |
||||
createdBy: dashboard.metadata.annotations?.['grafana.app/createdBy'] ?? '', |
||||
updated: dashboard.metadata.annotations?.['grafana.app/updatedTimestamp'], |
||||
updatedBy: dashboard.metadata.annotations?.['grafana.app/updatedBy'], |
||||
folderUid: dashboard.metadata.annotations?.['grafana.app/folder'], |
||||
slug: dashboard.metadata.annotations?.['grafana.app/slug'], |
||||
url: dashboard.access.url, |
||||
canAdmin: dashboard.access.canAdmin, |
||||
canDelete: dashboard.access.canDelete, |
||||
canEdit: dashboard.access.canEdit, |
||||
canSave: dashboard.access.canSave, |
||||
canShare: dashboard.access.canShare, |
||||
canStar: dashboard.access.canStar, |
||||
annotationsPermissions: dashboard.access.annotationsPermissions, |
||||
}, |
||||
dashboard: { |
||||
uid: dashboard.metadata.name, |
||||
title: spec.title, |
||||
description: spec.description, |
||||
tags: spec.tags, |
||||
schemaVersion: spec.schemaVersion, |
||||
// @ts-ignore TODO: Use transformers for these enums
|
||||
// graphTooltip: spec.cursorSync, // Assuming transformCursorSynctoEnum is reversible
|
||||
preload: spec.preload, |
||||
liveNow: spec.liveNow, |
||||
editable: spec.editable, |
||||
time: { |
||||
from: spec.timeSettings.from, |
||||
to: spec.timeSettings.to, |
||||
}, |
||||
timezone: spec.timeSettings.timezone, |
||||
refresh: spec.timeSettings.autoRefresh, |
||||
timepicker: { |
||||
refresh_intervals: spec.timeSettings.autoRefreshIntervals, |
||||
hidden: spec.timeSettings.hideTimepicker, |
||||
time_options: spec.timeSettings.quickRanges, |
||||
nowDelay: spec.timeSettings.nowDelay, |
||||
}, |
||||
fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth, |
||||
weekStart: spec.timeSettings.weekStart, |
||||
version: parseInt(dashboard.metadata.resourceVersion, 10), |
||||
links: spec.links, // Assuming transformDashboardLinksToEnums is reversible
|
||||
annotations: { list: [] }, // TODO
|
||||
}, |
||||
}; |
||||
} |
||||
} |
||||
|
||||
export const ResponseTransformers = { |
||||
ensureV2Response, |
||||
ensureV1Response, |
||||
}; |
@ -0,0 +1,75 @@ |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { getDashboardAPI, setDashboardAPI } from './dashboard_api'; |
||||
import { LegacyDashboardAPI } from './legacy'; |
||||
import { K8sDashboardAPI } from './v0'; |
||||
import { K8sDashboardV2APIStub } from './v2'; |
||||
|
||||
describe('DashboardApi', () => { |
||||
it('should use legacy api by default', () => { |
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); |
||||
}); |
||||
|
||||
it('should allow overriding clients in test environment', () => { |
||||
process.env.NODE_ENV = 'test'; |
||||
const mockClient = { legacy: new LegacyDashboardAPI() }; |
||||
setDashboardAPI(mockClient); |
||||
const api = getDashboardAPI(); |
||||
expect(api).toBe(mockClient.legacy); |
||||
setDashboardAPI(undefined); |
||||
}); |
||||
|
||||
describe('when scenes enabled', () => { |
||||
beforeEach(() => { |
||||
config.featureToggles.dashboardScene = true; |
||||
}); |
||||
|
||||
it('should use legacy api kubernetesDashboards toggle is disabled', () => { |
||||
config.featureToggles.kubernetesDashboards = false; |
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); |
||||
}); |
||||
|
||||
it('should use v0 api when and kubernetesDashboards toggle is enabled', () => { |
||||
config.featureToggles.kubernetesDashboards = true; |
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); |
||||
}); |
||||
|
||||
it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => { |
||||
config.featureToggles.useV2DashboardsAPI = true; |
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2APIStub); |
||||
}); |
||||
}); |
||||
|
||||
describe('when scenes disabled', () => { |
||||
beforeEach(() => { |
||||
config.featureToggles.dashboardScene = false; |
||||
}); |
||||
|
||||
it('should use legacy api when kubernetesDashboards toggle is disabled', () => { |
||||
config.featureToggles.kubernetesDashboards = undefined; |
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); |
||||
}); |
||||
|
||||
it('should use legacy api when kubernetesDashboards toggle is disabled', () => { |
||||
config.featureToggles.kubernetesDashboards = false; |
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); |
||||
}); |
||||
|
||||
it('should use v0 api when kubernetesDashboards toggle is enabled', () => { |
||||
config.featureToggles.kubernetesDashboards = true; |
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); |
||||
}); |
||||
|
||||
it('should use v0 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => { |
||||
config.featureToggles.useV2DashboardsAPI = true; |
||||
config.featureToggles.kubernetesDashboards = true; |
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); |
||||
}); |
||||
|
||||
it('should use legacy useV2DashboardsAPI toggle is enabled', () => { |
||||
config.featureToggles.useV2DashboardsAPI = true; |
||||
config.featureToggles.kubernetesDashboards = undefined; |
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); |
||||
}); |
||||
}); |
||||
}); |
@ -1,147 +1,52 @@ |
||||
import { UrlQueryMap } from '@grafana/data'; |
||||
import { config, getBackendSrv } from '@grafana/runtime'; |
||||
import { ScopedResourceClient } from 'app/features/apiserver/client'; |
||||
import { |
||||
AnnoKeyFolder, |
||||
AnnoKeyMessage, |
||||
Resource, |
||||
ResourceClient, |
||||
ResourceForCreate, |
||||
} from 'app/features/apiserver/types'; |
||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; |
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; |
||||
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; |
||||
import { DashboardDTO, DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types'; |
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; |
||||
import { DashboardDTO } from 'app/types'; |
||||
|
||||
export interface DashboardAPI { |
||||
/** Get a dashboard with the access control metadata */ |
||||
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<DashboardDTO>; |
||||
/** Save dashboard */ |
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO>; |
||||
/** Delete a dashboard */ |
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse>; |
||||
} |
||||
|
||||
// Implemented using /api/dashboards/*
|
||||
class LegacyDashboardAPI implements DashboardAPI { |
||||
constructor() {} |
||||
import { LegacyDashboardAPI } from './legacy'; |
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types'; |
||||
import { getDashboardsApiVersion } from './utils'; |
||||
import { K8sDashboardAPI } from './v0'; |
||||
import { K8sDashboardV2APIStub } from './v2'; |
||||
|
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> { |
||||
dashboardWatcher.ignoreNextSave(); |
||||
type DashboardAPIClients = { |
||||
legacy: DashboardAPI<DashboardDTO>; |
||||
v0: DashboardAPI<DashboardDTO>; |
||||
// v1: DashboardDTO; TODO[schema]: enable v1 when available
|
||||
v2: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>>; |
||||
}; |
||||
|
||||
return getBackendSrv().post<SaveDashboardResponseDTO>('/api/dashboards/db/', { |
||||
dashboard: options.dashboard, |
||||
message: options.message ?? '', |
||||
overwrite: options.overwrite ?? false, |
||||
folderUid: options.folderUid, |
||||
}); |
||||
} |
||||
type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>; |
||||
|
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> { |
||||
return getBackendSrv().delete<DeleteDashboardResponse>(`/api/dashboards/uid/${uid}`, { showSuccessAlert }); |
||||
} |
||||
let clients: Partial<DashboardAPIClients> | undefined; |
||||
|
||||
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<DashboardDTO> { |
||||
return getBackendSrv().get<DashboardDTO>(`/api/dashboards/uid/${uid}`, params); |
||||
export function setDashboardAPI(override: Partial<DashboardAPIClients> | undefined) { |
||||
if (process.env.NODE_ENV !== 'test') { |
||||
throw new Error('dashboardAPI can be only overridden in test environment'); |
||||
} |
||||
clients = override; |
||||
} |
||||
|
||||
export interface DashboardWithAccessInfo<T = DashboardDataDTO> extends Resource<T, 'DashboardWithAccessInfo'> { |
||||
access: Object; // TODO...
|
||||
} |
||||
|
||||
// Implemented using /apis/dashboard.grafana.app/*
|
||||
class K8sDashboardAPI implements DashboardAPI { |
||||
private client: ResourceClient<DashboardDataDTO>; |
||||
|
||||
constructor() { |
||||
this.client = new ScopedResourceClient<DashboardDataDTO>({ |
||||
group: 'dashboard.grafana.app', |
||||
version: 'v0alpha1', |
||||
resource: 'dashboards', |
||||
}); |
||||
} |
||||
|
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> { |
||||
const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property
|
||||
const obj: ResourceForCreate<DashboardDataDTO> = { |
||||
metadata: { |
||||
...options?.k8s, |
||||
}, |
||||
spec: { |
||||
...dashboard, |
||||
}, |
||||
}; |
||||
|
||||
if (options.message) { |
||||
obj.metadata.annotations = { |
||||
...obj.metadata.annotations, |
||||
[AnnoKeyMessage]: options.message, |
||||
}; |
||||
} else if (obj.metadata.annotations) { |
||||
delete obj.metadata.annotations[AnnoKeyMessage]; |
||||
} |
||||
|
||||
if (options.folderUid) { |
||||
obj.metadata.annotations = { |
||||
...obj.metadata.annotations, |
||||
[AnnoKeyFolder]: options.folderUid, |
||||
}; |
||||
} |
||||
|
||||
if (dashboard.uid) { |
||||
obj.metadata.name = dashboard.uid; |
||||
return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); |
||||
} |
||||
return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); |
||||
} |
||||
|
||||
asSaveDashboardResponseDTO(v: Resource<DashboardDataDTO>): SaveDashboardResponseDTO { |
||||
return { |
||||
uid: v.metadata.name, |
||||
version: v.spec.version ?? 0, |
||||
id: v.spec.id ?? 0, |
||||
status: 'success', |
||||
slug: '', |
||||
url: '', |
||||
// Overloads
|
||||
export function getDashboardAPI(): DashboardAPI<DashboardDTO>; |
||||
export function getDashboardAPI(requestV2Response: 'v2'): DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec>>; |
||||
export function getDashboardAPI(requestV2Response?: 'v2'): DashboardAPI<DashboardReturnTypes> { |
||||
const v = getDashboardsApiVersion(); |
||||
const isConvertingToV1 = !requestV2Response; |
||||
|
||||
if (!clients) { |
||||
clients = { |
||||
legacy: new LegacyDashboardAPI(), |
||||
v0: new K8sDashboardAPI(), |
||||
v2: new K8sDashboardV2APIStub(isConvertingToV1), |
||||
}; |
||||
} |
||||
|
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> { |
||||
return this.client.delete(uid).then((v) => ({ |
||||
id: 0, |
||||
message: v.message, |
||||
title: 'deleted', |
||||
})); |
||||
if (v === 'v2' && requestV2Response === 'v2') { |
||||
return new K8sDashboardV2APIStub(false); |
||||
} |
||||
|
||||
async getDashboardDTO(uid: string): Promise<DashboardDTO> { |
||||
const dash = await this.client.subresource<DashboardWithAccessInfo>(uid, 'dto'); |
||||
return { |
||||
meta: { |
||||
...dash.access, |
||||
isNew: false, |
||||
isFolder: false, |
||||
uid: dash.metadata.name, |
||||
k8s: dash.metadata, |
||||
}, |
||||
dashboard: dash.spec, |
||||
}; |
||||
if (!clients[v]) { |
||||
throw new Error(`Unknown Dashboard API version: ${v}`); |
||||
} |
||||
} |
||||
|
||||
let instance: DashboardAPI | undefined = undefined; |
||||
|
||||
export function getDashboardAPI() { |
||||
if (!instance) { |
||||
instance = config.featureToggles.kubernetesDashboards ? new K8sDashboardAPI() : new LegacyDashboardAPI(); |
||||
} |
||||
return instance; |
||||
} |
||||
|
||||
export function setDashboardAPI(override: DashboardAPI | undefined) { |
||||
if (process.env.NODE_ENV !== 'test') { |
||||
throw new Error('dashboardAPI can be only overridden in test environment'); |
||||
} |
||||
instance = override; |
||||
return clients[v]; |
||||
} |
||||
|
@ -0,0 +1,55 @@ |
||||
import { UrlQueryMap } from '@grafana/data'; |
||||
import { DashboardDTO } from 'app/types'; |
||||
|
||||
import { LegacyDashboardAPI } from './legacy'; |
||||
|
||||
const mockDashboardDto: DashboardDTO = { |
||||
meta: { |
||||
isFolder: false, |
||||
}, |
||||
dashboard: { |
||||
title: 'test', |
||||
uid: 'test', |
||||
schemaVersion: 0, |
||||
}, |
||||
}; |
||||
|
||||
const backendSrvGetSpy = jest.fn(); |
||||
jest.mock('@grafana/runtime', () => ({ |
||||
getBackendSrv: () => ({ |
||||
get: (dashUrl: string, params: Object) => { |
||||
backendSrvGetSpy(dashUrl, params); |
||||
const uid = dashUrl.split('/').pop(); |
||||
if (uid === 'folderUid') { |
||||
return Promise.resolve({ |
||||
meta: { |
||||
isFolder: true, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
return mockDashboardDto; |
||||
}, |
||||
}), |
||||
})); |
||||
|
||||
jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ |
||||
ignoreNextSave: jest.fn(), |
||||
})); |
||||
|
||||
describe('Legacy dashboard API', () => { |
||||
it('should throw an error if requesting a folder', async () => { |
||||
const api = new LegacyDashboardAPI(); |
||||
expect(async () => await api.getDashboardDTO('folderUid')).rejects.toThrowError('Dashboard not found'); |
||||
}); |
||||
|
||||
it('should return a valid dashboard', async () => { |
||||
const api = new LegacyDashboardAPI(); |
||||
const params: UrlQueryMap = { |
||||
param: 1, |
||||
}; |
||||
const result = await api.getDashboardDTO('validUid', params); |
||||
expect(result).toEqual(mockDashboardDto); |
||||
expect(backendSrvGetSpy).toHaveBeenLastCalledWith('/api/dashboards/uid/validUid', params); |
||||
}); |
||||
}); |
@ -0,0 +1,40 @@ |
||||
import { AppEvents, UrlQueryMap } from '@grafana/data'; |
||||
import { getBackendSrv } from '@grafana/runtime'; |
||||
import appEvents from 'app/core/app_events'; |
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; |
||||
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; |
||||
import { SaveDashboardResponseDTO, DashboardDTO } from 'app/types'; |
||||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types'; |
||||
|
||||
import { DashboardAPI } from './types'; |
||||
|
||||
export class LegacyDashboardAPI implements DashboardAPI<DashboardDTO> { |
||||
constructor() {} |
||||
|
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> { |
||||
dashboardWatcher.ignoreNextSave(); |
||||
|
||||
return getBackendSrv().post<SaveDashboardResponseDTO>('/api/dashboards/db/', { |
||||
dashboard: options.dashboard, |
||||
message: options.message ?? '', |
||||
overwrite: options.overwrite ?? false, |
||||
folderUid: options.folderUid, |
||||
}); |
||||
} |
||||
|
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> { |
||||
return getBackendSrv().delete<DeleteDashboardResponse>(`/api/dashboards/uid/${uid}`, { showSuccessAlert }); |
||||
} |
||||
|
||||
async getDashboardDTO(uid: string, params?: UrlQueryMap) { |
||||
const result = await getBackendSrv().get<DashboardDTO>(`/api/dashboards/uid/${uid}`, params); |
||||
|
||||
if (result.meta.isFolder) { |
||||
appEvents.emit(AppEvents.alertError, ['Dashboard not found']); |
||||
throw new Error('Dashboard not found'); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
} |
@ -0,0 +1,31 @@ |
||||
import { UrlQueryMap } from '@grafana/data'; |
||||
import { Resource } from 'app/features/apiserver/types'; |
||||
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; |
||||
import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types'; |
||||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types'; |
||||
|
||||
export interface DashboardAPI<G> { |
||||
/** Get a dashboard with the access control metadata */ |
||||
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<G>; |
||||
/** Save dashboard */ |
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO>; |
||||
/** Delete a dashboard */ |
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse>; |
||||
} |
||||
|
||||
// Implemented using /api/dashboards/*
|
||||
export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAccessInfo'> { |
||||
access: { |
||||
url?: string; |
||||
slug?: string; |
||||
canSave?: boolean; |
||||
canEdit?: boolean; |
||||
canDelete?: boolean; |
||||
canShare?: boolean; |
||||
canStar?: boolean; |
||||
canAdmin?: boolean; |
||||
annotationsPermissions?: AnnotationsPermissions; |
||||
isNew?: boolean; |
||||
}; // TODO...
|
||||
} |
@ -0,0 +1,51 @@ |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { getDashboardsApiVersion } from './utils'; |
||||
|
||||
describe('getDashboardsApiVersion', () => { |
||||
beforeEach(() => { |
||||
jest.resetModules(); |
||||
}); |
||||
|
||||
it('should return v0 when dashboardScene is disabled and kubernetesDashboards is enabled', () => { |
||||
config.featureToggles = { |
||||
dashboardScene: false, |
||||
kubernetesDashboards: true, |
||||
}; |
||||
expect(getDashboardsApiVersion()).toBe('v0'); |
||||
}); |
||||
|
||||
it('should return legacy when dashboardScene is disabled and kubernetesDashboards is disabled', () => { |
||||
config.featureToggles = { |
||||
dashboardScene: false, |
||||
kubernetesDashboards: false, |
||||
}; |
||||
expect(getDashboardsApiVersion()).toBe('legacy'); |
||||
}); |
||||
|
||||
it('should return v2 when dashboardScene is enabled and useV2DashboardsAPI is enabled', () => { |
||||
config.featureToggles = { |
||||
dashboardScene: true, |
||||
useV2DashboardsAPI: true, |
||||
}; |
||||
expect(getDashboardsApiVersion()).toBe('v2'); |
||||
}); |
||||
|
||||
it('should return v0 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => { |
||||
config.featureToggles = { |
||||
dashboardScene: true, |
||||
useV2DashboardsAPI: false, |
||||
kubernetesDashboards: true, |
||||
}; |
||||
expect(getDashboardsApiVersion()).toBe('v0'); |
||||
}); |
||||
|
||||
it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => { |
||||
config.featureToggles = { |
||||
dashboardScene: true, |
||||
useV2DashboardsAPI: false, |
||||
kubernetesDashboards: false, |
||||
}; |
||||
expect(getDashboardsApiVersion()).toBe('legacy'); |
||||
}); |
||||
}); |
@ -0,0 +1,47 @@ |
||||
import { config } from '@grafana/runtime'; |
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; |
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types'; |
||||
|
||||
import { DashboardWithAccessInfo } from './types'; |
||||
|
||||
export function getDashboardsApiVersion() { |
||||
// if dashboard scene is disabled, use legacy API response for the old architecture
|
||||
if (!config.featureToggles.dashboardScene) { |
||||
// for old architecture, use v0 API for k8s dashboards
|
||||
if (config.featureToggles.kubernetesDashboards) { |
||||
return 'v0'; |
||||
} |
||||
|
||||
return 'legacy'; |
||||
} |
||||
|
||||
if (config.featureToggles.useV2DashboardsAPI) { |
||||
return 'v2'; |
||||
} |
||||
|
||||
if (config.featureToggles.kubernetesDashboards) { |
||||
return 'v0'; |
||||
} |
||||
|
||||
return 'legacy'; |
||||
} |
||||
|
||||
// This function is used to determine if the dashboard is in v2 format or also v0 format
|
||||
export function isDashboardResource( |
||||
obj?: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> | null |
||||
): obj is DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> { |
||||
if (!obj) { |
||||
return false; |
||||
} |
||||
// is v0 or v2 format?
|
||||
const isK8sDashboard = 'kind' in obj && obj.kind === 'DashboardWithAccessInfo'; |
||||
return isK8sDashboard; |
||||
} |
||||
|
||||
export function isDashboardV2Spec(obj: object): obj is DashboardV2Spec { |
||||
return 'elements' in obj; |
||||
} |
||||
|
||||
export function isDashboardV0Spec(obj: object): obj is DashboardDataDTO { |
||||
return !isDashboardV2Spec(obj); // not v2 spec means it's v0 spec
|
||||
} |
@ -0,0 +1,65 @@ |
||||
import { backendSrv } from 'app/core/services/backend_srv'; |
||||
import { AnnoKeyFolder } from 'app/features/apiserver/types'; |
||||
import { DashboardDataDTO } from 'app/types'; |
||||
|
||||
import { DashboardWithAccessInfo } from './types'; |
||||
import { K8sDashboardAPI } from './v0'; |
||||
|
||||
const mockDashboardDto: DashboardWithAccessInfo<DashboardDataDTO> = { |
||||
kind: 'DashboardWithAccessInfo', |
||||
apiVersion: 'v0alpha1', |
||||
|
||||
metadata: { |
||||
name: 'dash-uid', |
||||
resourceVersion: '1', |
||||
creationTimestamp: '1', |
||||
annotations: { |
||||
[AnnoKeyFolder]: 'new-folder', |
||||
}, |
||||
}, |
||||
spec: { |
||||
title: 'test', |
||||
uid: 'test', |
||||
schemaVersion: 0, |
||||
}, |
||||
access: {}, |
||||
}; |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
getBackendSrv: () => ({ |
||||
get: () => mockDashboardDto, |
||||
}), |
||||
config: {}, |
||||
})); |
||||
|
||||
jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ |
||||
ignoreNextSave: jest.fn(), |
||||
})); |
||||
|
||||
describe('v0 dashboard API', () => { |
||||
it('should provide folder annotations', async () => { |
||||
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ |
||||
id: 1, |
||||
uid: 'new-folder', |
||||
title: 'New Folder', |
||||
url: '/folder/url', |
||||
canAdmin: true, |
||||
canDelete: true, |
||||
canEdit: true, |
||||
canSave: true, |
||||
created: '', |
||||
createdBy: '', |
||||
hasAcl: false, |
||||
updated: '', |
||||
updatedBy: '', |
||||
}); |
||||
|
||||
const api = new K8sDashboardAPI(); |
||||
const result = await api.getDashboardDTO('test'); |
||||
expect(result.meta.isFolder).toBe(false); |
||||
expect(result.meta.folderId).toBe(1); |
||||
expect(result.meta.folderTitle).toBe('New Folder'); |
||||
expect(result.meta.folderUrl).toBe('/folder/url'); |
||||
expect(result.meta.folderUid).toBe('new-folder'); |
||||
}); |
||||
}); |
@ -0,0 +1,109 @@ |
||||
import { backendSrv } from 'app/core/services/backend_srv'; |
||||
import { ScopedResourceClient } from 'app/features/apiserver/client'; |
||||
import { |
||||
ResourceClient, |
||||
ResourceForCreate, |
||||
AnnoKeyMessage, |
||||
AnnoKeyFolder, |
||||
Resource, |
||||
} from 'app/features/apiserver/types'; |
||||
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; |
||||
import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; |
||||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types'; |
||||
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types'; |
||||
|
||||
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO> { |
||||
private client: ResourceClient<DashboardDataDTO>; |
||||
|
||||
constructor() { |
||||
this.client = new ScopedResourceClient<DashboardDataDTO>({ |
||||
group: 'dashboard.grafana.app', |
||||
version: 'v0alpha1', |
||||
resource: 'dashboards', |
||||
}); |
||||
} |
||||
|
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> { |
||||
const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property
|
||||
const obj: ResourceForCreate<DashboardDataDTO> = { |
||||
metadata: { |
||||
...options?.k8s, |
||||
}, |
||||
spec: { |
||||
...dashboard, |
||||
}, |
||||
}; |
||||
|
||||
if (options.message) { |
||||
obj.metadata.annotations = { |
||||
...obj.metadata.annotations, |
||||
[AnnoKeyMessage]: options.message, |
||||
}; |
||||
} else if (obj.metadata.annotations) { |
||||
delete obj.metadata.annotations[AnnoKeyMessage]; |
||||
} |
||||
|
||||
if (options.folderUid) { |
||||
obj.metadata.annotations = { |
||||
...obj.metadata.annotations, |
||||
[AnnoKeyFolder]: options.folderUid, |
||||
}; |
||||
} |
||||
|
||||
if (dashboard.uid) { |
||||
obj.metadata.name = dashboard.uid; |
||||
return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); |
||||
} |
||||
return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); |
||||
} |
||||
|
||||
asSaveDashboardResponseDTO(v: Resource<DashboardDataDTO>): SaveDashboardResponseDTO { |
||||
return { |
||||
uid: v.metadata.name, |
||||
version: v.spec.version ?? 0, |
||||
id: v.spec.id ?? 0, |
||||
status: 'success', |
||||
slug: '', |
||||
url: '', |
||||
}; |
||||
} |
||||
|
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> { |
||||
return this.client.delete(uid).then((v) => ({ |
||||
id: 0, |
||||
message: v.message, |
||||
title: 'deleted', |
||||
})); |
||||
} |
||||
|
||||
async getDashboardDTO(uid: string) { |
||||
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto'); |
||||
|
||||
const result: DashboardDTO = { |
||||
meta: { |
||||
...dash.access, |
||||
isNew: false, |
||||
isFolder: false, |
||||
uid: dash.metadata.name, |
||||
k8s: dash.metadata, |
||||
}, |
||||
dashboard: dash.spec, |
||||
}; |
||||
|
||||
if (dash.metadata.annotations?.[AnnoKeyFolder]) { |
||||
try { |
||||
const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]); |
||||
result.meta.folderTitle = folder.title; |
||||
result.meta.folderUrl = folder.url; |
||||
result.meta.folderUid = folder.uid; |
||||
result.meta.folderId = folder.id; |
||||
} catch (e) { |
||||
console.error('Failed to load a folder', e); |
||||
} |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
} |
@ -0,0 +1,69 @@ |
||||
import { |
||||
DashboardV2Spec, |
||||
defaultDashboardV2Spec, |
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; |
||||
import { backendSrv } from 'app/core/services/backend_srv'; |
||||
import { AnnoKeyFolder, AnnoKeyFolderId, AnnoKeyFolderTitle, AnnoKeyFolderUrl } from 'app/features/apiserver/types'; |
||||
|
||||
import { DashboardWithAccessInfo } from './types'; |
||||
import { K8sDashboardV2APIStub } from './v2'; |
||||
|
||||
const mockDashboardDto: DashboardWithAccessInfo<DashboardV2Spec> = { |
||||
kind: 'DashboardWithAccessInfo', |
||||
apiVersion: 'v0alpha1', |
||||
|
||||
metadata: { |
||||
name: 'dash-uid', |
||||
resourceVersion: '1', |
||||
creationTimestamp: '1', |
||||
annotations: { |
||||
[AnnoKeyFolder]: 'new-folder', |
||||
}, |
||||
}, |
||||
spec: { |
||||
...defaultDashboardV2Spec(), |
||||
}, |
||||
access: {}, |
||||
}; |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
getBackendSrv: () => ({ |
||||
get: () => mockDashboardDto, |
||||
}), |
||||
config: {}, |
||||
})); |
||||
|
||||
jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ |
||||
ignoreNextSave: jest.fn(), |
||||
})); |
||||
|
||||
describe('v2 dashboard API', () => { |
||||
it('should provide folder annotations', async () => { |
||||
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ |
||||
id: 1, |
||||
uid: 'new-folder', |
||||
title: 'New Folder', |
||||
url: '/folder/url', |
||||
canAdmin: true, |
||||
canDelete: true, |
||||
canEdit: true, |
||||
canSave: true, |
||||
created: '', |
||||
createdBy: '', |
||||
hasAcl: false, |
||||
updated: '', |
||||
updatedBy: '', |
||||
}); |
||||
|
||||
const convertToV1 = false; |
||||
const api = new K8sDashboardV2APIStub(convertToV1); |
||||
// 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
|
||||
const result = (await api.getDashboardDTO('test')) as DashboardWithAccessInfo<DashboardV2Spec>; |
||||
expect(result.metadata.annotations![AnnoKeyFolderId]).toBe(1); |
||||
expect(result.metadata.annotations![AnnoKeyFolderTitle]).toBe('New Folder'); |
||||
expect(result.metadata.annotations![AnnoKeyFolderUrl]).toBe('/folder/url'); |
||||
expect(result.metadata.annotations![AnnoKeyFolder]).toBe('new-folder'); |
||||
}); |
||||
}); |
@ -0,0 +1,68 @@ |
||||
import { UrlQueryMap } from '@grafana/data'; |
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; |
||||
import { backendSrv } from 'app/core/services/backend_srv'; |
||||
import { ScopedResourceClient } from 'app/features/apiserver/client'; |
||||
import { |
||||
AnnoKeyFolder, |
||||
AnnoKeyFolderId, |
||||
AnnoKeyFolderTitle, |
||||
AnnoKeyFolderUrl, |
||||
ResourceClient, |
||||
} from 'app/features/apiserver/types'; |
||||
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; |
||||
import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; |
||||
|
||||
import { SaveDashboardCommand } from '../components/SaveDashboard/types'; |
||||
|
||||
import { ResponseTransformers } from './ResponseTransformers'; |
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types'; |
||||
|
||||
export class K8sDashboardV2APIStub implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO> { |
||||
private client: ResourceClient<DashboardV2Spec>; |
||||
|
||||
constructor(private convertToV1: boolean) { |
||||
this.client = new ScopedResourceClient<DashboardV2Spec>({ |
||||
group: 'dashboard.grafana.app', |
||||
version: 'v2alpha1', |
||||
resource: 'dashboards', |
||||
}); |
||||
} |
||||
|
||||
async getDashboardDTO(uid: string, params?: UrlQueryMap) { |
||||
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); |
||||
|
||||
// load folder info if available
|
||||
if (result.metadata.annotations && result.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; |
||||
} catch (e) { |
||||
console.error('Failed to load a folder', e); |
||||
} |
||||
} |
||||
|
||||
// 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; |
||||
} |
||||
|
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> { |
||||
throw new Error('Method not implemented.'); |
||||
} |
||||
|
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> { |
||||
throw new Error('Method not implemented.'); |
||||
} |
||||
} |
@ -0,0 +1,64 @@ |
||||
import { |
||||
DashboardV2Spec, |
||||
defaultDashboardV2Spec, |
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; |
||||
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types'; |
||||
import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; |
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; |
||||
import { DashboardDTO } from 'app/types'; |
||||
|
||||
import { validateUid } from './validation'; |
||||
|
||||
const legacyDashboard: DashboardDTO = { |
||||
dashboard: { |
||||
title: 'Legacy Dashboard', |
||||
schemaVersion: 16, |
||||
uid: 'dashboard-uid', |
||||
}, |
||||
meta: { |
||||
folderTitle: 'Folder title', |
||||
folderUid: 'folder-uid', |
||||
}, |
||||
}; |
||||
|
||||
const v2Dashboard: DashboardWithAccessInfo<DashboardV2Spec> = { |
||||
kind: 'DashboardWithAccessInfo', |
||||
apiVersion: 'v2alpha0', |
||||
metadata: { |
||||
creationTimestamp: '2021-09-29T14:00:00Z', |
||||
name: 'dashboard-uid', |
||||
resourceVersion: '1', |
||||
annotations: { |
||||
[AnnoKeyFolder]: 'folder-uid', |
||||
[AnnoKeyFolderTitle]: 'folder-title', |
||||
}, |
||||
}, |
||||
access: {}, |
||||
spec: { |
||||
...defaultDashboardV2Spec(), |
||||
title: 'V2 Dashboard', |
||||
}, |
||||
}; |
||||
|
||||
describe('validateUid', () => { |
||||
beforeAll(() => { |
||||
setDashboardAPI({ |
||||
legacy: { |
||||
getDashboardDTO: jest.fn().mockResolvedValue(legacyDashboard), |
||||
deleteDashboard: jest.fn(), |
||||
saveDashboard: jest.fn(), |
||||
}, |
||||
v2: { |
||||
getDashboardDTO: jest.fn().mockResolvedValue(v2Dashboard), |
||||
deleteDashboard: jest.fn(), |
||||
saveDashboard: jest.fn(), |
||||
}, |
||||
}); |
||||
}); |
||||
describe('Dashboards API v1', () => { |
||||
it('should return a message with the existing dashboard title and folder title', async () => { |
||||
const result = await validateUid('dashboard-uid'); |
||||
expect(result).toBe(`Dashboard named 'Legacy Dashboard' in folder 'Folder title' has the same UID`); |
||||
}); |
||||
}); |
||||
}); |
Loading…
Reference in new issue