Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
b322f65243
118 files changed
+2693
-772
No files matched your search
+6
-1
@@ -3459,7 +3459,12 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||
],
|
||||
"public/app/features/dashboard/api/dashboard_api.ts:5381": [
|
||||
"public/app/features/dashboard/api/ResponseTransformers.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||
],
|
||||
"public/app/features/dashboard/api/v0.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx:5381": [
|
||||
|
||||
+2
-2
@@ -74,7 +74,7 @@ The following list contains role-based access control actions.
|
||||
| `banners:write` | None | Create [announcement banners](/docs/grafana-cloud/whats-new/2024-09-10-announcement-banner/). |
|
||||
| `dashboards:create` | <ul><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Create dashboards in one or more folders and their subfolders. |
|
||||
| `dashboards:delete` | <ul><li>`dashboards:*`</li><li>`dashboards:uid:*`</li><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Delete one or more dashboards. |
|
||||
| `dashboards.insights:read` | None | Read dashboard insights data and see presence indicators. |
|
||||
| `dashboards.insights:read` | None | Read dashboard insights data and see presence indicators. To view insights, `dashboards:read` on the dashboard is also needed. |
|
||||
| `dashboards.permissions:read` | <ul><li>`dashboards:*`</li><li>`dashboards:uid:*`</li><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Read permissions for one or more dashboards. |
|
||||
| `dashboards.permissions:write` | <ul><li>`dashboards:*`</li><li>`dashboards:uid:*`</li><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Update permissions for one or more dashboards. |
|
||||
| `dashboards:read` | <ul><li>`dashboards:*`</li><li>`dashboards:uid:*`</li><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Read one or more dashboards. |
|
||||
@@ -86,7 +86,7 @@ The following list contains role-based access control actions.
|
||||
| `datasources:delete` | <ul><li>`datasources:*`</li><li>`datasources:uid:*`</li></ul> | Delete data sources. |
|
||||
| `datasources:explore` | None | Enable access to the **Explore** tab. |
|
||||
| `datasources.id:read` | <ul><li>`datasources:*`</li><li>`datasources:uid:*`</li></ul> | Read data source IDs. |
|
||||
| `datasources.insights:read` | None | Read data sources insights data. |
|
||||
| `datasources.insights:read` | None | Read data sources insights data. To view insights, `datasources:read` on the datasource is also needed. |
|
||||
| `datasources.permissions:read` | <ul><li>`datasources:*`</li><li>`datasources:uid:*`</li></ul> | List data source permissions. |
|
||||
| `datasources.permissions:write` | <ul><li>`datasources:*`</li><li>`datasources:uid:*`</li></ul> | Update data source permissions. |
|
||||
| `datasources:query` | <ul><li>`datasources:*`</li><li>`datasources:uid:*`</li></ul> | Query data sources. |
|
||||
|
||||
@@ -66,9 +66,9 @@ Grafana enables you to share dashboards and panels with other users within your
|
||||
- Reports
|
||||
- Library panels
|
||||
|
||||
You must have an authorized viewer permission to see an image rendered by a direct link.
|
||||
You can also invite new members to your organization from the **Share** menu. For more information, refer to [Invite new members](#invite-new-members).
|
||||
|
||||
The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance.
|
||||
You must have an authorized viewer permission to see an image rendered by a direct link. The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Anonymous access permission is not available in Grafana Cloud. This feature is only supported for Grafana Enterprise and Grafana Open Source.
|
||||
@@ -341,3 +341,23 @@ To delete existing snapshots, follow these steps:
|
||||
1. Click the red **x** next to the snapshot URL that you want to delete.
|
||||
|
||||
The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this.
|
||||
|
||||
## Invite new members
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
This feature is only available on Grafana Cloud.
|
||||
{{< /admonition >}}
|
||||
|
||||
You can invite new members to your organization using the **Share** drop-down menu. You must have the `OrgUsersAdd` permission to use this feature.
|
||||
|
||||
To invite a new member to your organization, follow these steps:
|
||||
|
||||
1. Click **Dashboards** in the main menu and open any dashboard.
|
||||
1. Click the **Share** drop-down list in the top-right corner and select **Invite new member**.
|
||||
|
||||
The **Members** page of your Grafana Cloud Portal opens.
|
||||
|
||||
1. Enter the email address of the new member in the provided field.
|
||||
1. Make a selection in the **Role** drop-down list.
|
||||
1. (Optional) Select the **Receive billing emails** checkbox, if applicable.
|
||||
1. Click **Invite**.
|
||||
@@ -227,6 +227,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `crashDetection` | Enables browser crash detection reporting to Faro. |
|
||||
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy |
|
||||
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section |
|
||||
| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards |
|
||||
| `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import { MyQuery, MyDataSourceOptions, DEFAULT_QUERY, DataSourceResponse } from './types';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { VariableSupport } from './variables';
|
||||
|
||||
export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {
|
||||
baseUrl: string;
|
||||
@@ -18,6 +19,7 @@ export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {
|
||||
constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) {
|
||||
super(instanceSettings);
|
||||
this.baseUrl = instanceSettings.url!;
|
||||
this.variables = new VariableSupport();
|
||||
}
|
||||
|
||||
getDefaultQuery(_: CoreApp): Partial<MyQuery> {
|
||||
@@ -34,18 +36,17 @@ export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {
|
||||
const from = range!.from.valueOf();
|
||||
const to = range!.to.valueOf();
|
||||
|
||||
// Return a constant for each query.
|
||||
const data = options.targets.map((target) => {
|
||||
return createDataFrame({
|
||||
refId: target.refId,
|
||||
fields: [
|
||||
{ name: 'Time', values: [from, to], type: FieldType.time },
|
||||
{ name: 'Value', values: [target.constant, target.constant], type: FieldType.number },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return { data };
|
||||
return {
|
||||
data: [
|
||||
createDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', values: [from, to], type: FieldType.time },
|
||||
{ name: 'Value', values: ['A', 'B'], type: FieldType.string },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async request(url: string, params?: string) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test('should render variable editor', async ({ variableEditPage, page }) => {
|
||||
await variableEditPage.datasource.set('gdev-e2etestdatasource');
|
||||
await expect(page.getByRole('textbox', { name: 'Query Text' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('create new, successful variable query', async ({ variableEditPage, page }) => {
|
||||
await variableEditPage.datasource.set('gdev-e2etestdatasource');
|
||||
await page.getByRole('textbox', { name: 'Query Text' }).fill('variableQuery');
|
||||
await variableEditPage.runQuery();
|
||||
await expect(variableEditPage).toDisplayPreviews(['A', 'B']);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VariableSupportBase, VariableSupportType } from '@grafana/data';
|
||||
import { DEFAULT_QUERY, MyQuery } from './types';
|
||||
import { DataSource } from './datasource';
|
||||
|
||||
export class VariableSupport extends VariableSupportBase<DataSource> {
|
||||
getType(): VariableSupportType {
|
||||
return VariableSupportType.Datasource;
|
||||
}
|
||||
|
||||
getDefaultQuery(): Partial<MyQuery> {
|
||||
return DEFAULT_QUERY;
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,8 @@ github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrd
|
||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw=
|
||||
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
|
||||
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
|
||||
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
|
||||
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9 h1:7kQgkwGRoLzC9K0oyXdJo7nve/bynv/KwUsxbiTlzAM=
|
||||
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19 h1:iXUgAaqDcIUGbRoy2TdeofRG/j1zpGRSEmNK05T+bi8=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||
|
||||
+10
-10
@@ -155,8 +155,8 @@
|
||||
"@types/webpack-assets-manifest": "^5",
|
||||
"@types/webpack-env": "^1.18.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript-eslint/eslint-plugin": "8.9.0",
|
||||
"@typescript-eslint/parser": "8.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.1",
|
||||
"@typescript-eslint/parser": "8.18.1",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.2.1",
|
||||
"blob-polyfill": "9.0.20240710",
|
||||
@@ -263,7 +263,7 @@
|
||||
"@grafana/azure-sdk": "0.0.5",
|
||||
"@grafana/data": "workspace:*",
|
||||
"@grafana/e2e-selectors": "workspace:*",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/faro-core": "^1.3.6",
|
||||
"@grafana/faro-web-sdk": "^1.3.6",
|
||||
"@grafana/faro-web-tracing": "^1.8.2",
|
||||
@@ -275,8 +275,8 @@
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/saga-icons": "workspace:*",
|
||||
"@grafana/scenes": "^5.32.0",
|
||||
"@grafana/scenes-react": "^5.32.0",
|
||||
"@grafana/scenes": "^5.34.0",
|
||||
"@grafana/scenes-react": "^5.34.0",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
@@ -302,11 +302,11 @@
|
||||
"@react-awesome-query-builder/ui": "6.6.4",
|
||||
"@reduxjs/toolkit": "2.5.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@visx/event": "3.3.0",
|
||||
"@visx/gradient": "3.3.0",
|
||||
"@visx/group": "3.3.0",
|
||||
"@visx/shape": "3.5.0",
|
||||
"@visx/tooltip": "3.3.0",
|
||||
"@visx/event": "3.12.0",
|
||||
"@visx/gradient": "3.12.0",
|
||||
"@visx/group": "3.12.0",
|
||||
"@visx/shape": "3.12.0",
|
||||
"@visx/tooltip": "3.12.0",
|
||||
"@welldone-software/why-did-you-render": "8.0.3",
|
||||
"angular": "1.8.3",
|
||||
"angular-bindonce": "0.3.1",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/lodash": "4.17.13",
|
||||
|
||||
@@ -240,6 +240,7 @@ export interface FeatureToggles {
|
||||
alertingUIOptimizeReducer?: boolean;
|
||||
azureMonitorEnableUserAuth?: boolean;
|
||||
alertingNotificationsStepMode?: boolean;
|
||||
useV2DashboardsAPI?: boolean;
|
||||
feedbackButton?: boolean;
|
||||
elasticsearchCrossClusterSearch?: boolean;
|
||||
unifiedHistory?: boolean;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"postpack": "mv package.json.bak package.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/semver": "7.5.8",
|
||||
"esbuild": "0.24.0",
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('Resolver', () => {
|
||||
);
|
||||
expect(pages.Alerting.AddAlertRule.url).toBe('/alerting/new/alerting');
|
||||
expect(pages.AddDashboard.Settings.Variables.Edit.url('test')).toBe(
|
||||
'/dashboard/new?orgId=1&editview=templating&editIndex=test'
|
||||
'/dashboard/new?orgId=1&editview=variables&editIndex=test'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -155,8 +155,9 @@ export const versionedPages = {
|
||||
},
|
||||
Edit: {
|
||||
url: {
|
||||
[MIN_GRAFANA_VERSION]: (annotationIndex: string) =>
|
||||
`/dashboard/new?orgId=1&editview=templating&editIndex=${annotationIndex}`,
|
||||
'11.3.0': (editIndex: string) => `/dashboard/new?orgId=1&editview=variables&editIndex=${editIndex}`,
|
||||
[MIN_GRAFANA_VERSION]: (editIndex: string) =>
|
||||
`/dashboard/new?orgId=1&editview=templating&editIndex=${editIndex}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -205,6 +206,9 @@ export const versionedPages = {
|
||||
shareSnapshot: {
|
||||
'11.2.0': 'data-testid new share button share snapshot',
|
||||
},
|
||||
inviteUser: {
|
||||
'11.5.0': 'data-testid new share button invite user',
|
||||
},
|
||||
},
|
||||
},
|
||||
NewExportButton: {
|
||||
@@ -344,6 +348,7 @@ export const versionedPages = {
|
||||
Variables: {
|
||||
List: {
|
||||
url: {
|
||||
'11.3.0': (dashboardUid: string) => `/d/${dashboardUid}?editview=variables`,
|
||||
[MIN_GRAFANA_VERSION]: (dashboardUid: string) => `/d/${dashboardUid}?editview=templating`,
|
||||
},
|
||||
addVariableCTAV2: {
|
||||
@@ -376,6 +381,7 @@ export const versionedPages = {
|
||||
},
|
||||
Edit: {
|
||||
urlParams: {
|
||||
'11.3.0': (editIndex: string) => `editview=variables&editIndex=${editIndex}`,
|
||||
[MIN_GRAFANA_VERSION]: (editIndex: string) => `editview=templating&editIndex=${editIndex}`,
|
||||
},
|
||||
General: {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "^6.1.2",
|
||||
"@testing-library/react": "16.1.0",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-typescript": "^12.1.0",
|
||||
"@svgr/babel-plugin-remove-jsx-attribute": "^8.0.0",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/e2e-selectors": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/schema": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@emotion/css": "11.13.5",
|
||||
"@floating-ui/react": "0.27.2",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/faro-web-sdk": "1.12.2",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/schema": "11.5.0-pre",
|
||||
@@ -79,7 +79,7 @@
|
||||
"@grafana/e2e-selectors": "11.5.0-pre",
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-image": "3.0.3",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@swc/core": "1.10.1",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
@@ -101,8 +101,8 @@
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.9.0",
|
||||
"@typescript-eslint/parser": "8.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.1",
|
||||
"@typescript-eslint/parser": "8.18.1",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"css-loader": "7.1.2",
|
||||
"esbuild": "0.24.0",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@rollup/plugin-terser": "0.4.4",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/react": "16.1.0",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"esbuild": "0.24.0",
|
||||
"glob": "^11.0.0",
|
||||
"rimraf": "6.0.1",
|
||||
|
||||
@@ -694,7 +694,7 @@ export const defaultGridLayoutKind = (): GridLayoutKind => ({
|
||||
});
|
||||
|
||||
export interface PanelSpec {
|
||||
uid: string;
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
links: DataLink[];
|
||||
@@ -704,7 +704,7 @@ export interface PanelSpec {
|
||||
}
|
||||
|
||||
export const defaultPanelSpec = (): PanelSpec => ({
|
||||
uid: "",
|
||||
id: 0,
|
||||
title: "",
|
||||
description: "",
|
||||
links: [],
|
||||
|
||||
@@ -479,7 +479,7 @@ GridLayoutKind: {
|
||||
}
|
||||
|
||||
PanelSpec: {
|
||||
uid: string
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
links: [...DataLink]
|
||||
|
||||
@@ -108,7 +108,7 @@ export const handyTestingSchema: DashboardV2Spec = {
|
||||
},
|
||||
],
|
||||
elements: {
|
||||
'test-panel-uid': {
|
||||
'panel-1': {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
data: {
|
||||
@@ -168,7 +168,7 @@ export const handyTestingSchema: DashboardV2Spec = {
|
||||
{ title: 'Test Link 2', url: 'http://test2.com' },
|
||||
],
|
||||
title: 'Test Panel',
|
||||
uid: 'test-panel-uid',
|
||||
id: 1,
|
||||
vizConfig: {
|
||||
kind: 'timeseries',
|
||||
spec: {
|
||||
@@ -192,7 +192,7 @@ export const handyTestingSchema: DashboardV2Spec = {
|
||||
spec: {
|
||||
element: {
|
||||
kind: 'ElementReference',
|
||||
name: 'test-panel-uid',
|
||||
name: 'panel-1',
|
||||
},
|
||||
height: 100,
|
||||
width: 200,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/e2e-selectors": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
"@react-awesome-query-builder/ui": "6.6.4",
|
||||
|
||||
@@ -16,4 +16,4 @@ See [package source](https://github.com/grafana/grafana/tree/main/packages/grafa
|
||||
|
||||
## Development
|
||||
|
||||
For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `yarn link`. Then, navigate to your project and run `yarn link @grafana/ui` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead.
|
||||
For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `YARN_IGNORE_PATH=1 yarn link`. Then, navigate to your project and run `yarn link "@grafana/ui"` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead.
|
||||
@@ -116,7 +116,7 @@
|
||||
"@babel/core": "7.26.0",
|
||||
"@faker-js/faker": "^9.0.0",
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@storybook/addon-a11y": "^8.4.2",
|
||||
"@storybook/addon-actions": "^8.4.2",
|
||||
"@storybook/addon-docs": "^8.4.2",
|
||||
|
||||
@@ -40,7 +40,7 @@ export const VizTooltipFooter = ({ dataLinks, actions, annotate }: VizTooltipFoo
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{dataLinks.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks, styles)}</div>}
|
||||
{dataLinks?.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks, styles)}</div>}
|
||||
{actions && actions.length > 0 && <div className={styles.dataLinks}>{renderActions(actions)}</div>}
|
||||
{annotate != null && (
|
||||
<div className={styles.addAnnotations}>
|
||||
|
||||
@@ -85,6 +85,7 @@ export { PageToolbar } from './PageLayout/PageToolbar';
|
||||
export { SetInterval } from './SetInterval/SetInterval';
|
||||
|
||||
export { Table } from './Table/Table';
|
||||
export { TableCellInspector, TableCellInspectorMode } from './Table/TableCellInspector';
|
||||
export {
|
||||
type TableCustomCellOptions,
|
||||
type CustomCellRendererProps,
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { DashboardCursorSync } from '@grafana/schema';
|
||||
|
||||
import { useStyles2 } from '../../../themes';
|
||||
@@ -27,6 +27,8 @@ export const enum TooltipHoverMode {
|
||||
xyOne,
|
||||
}
|
||||
|
||||
type GeDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[];
|
||||
|
||||
interface TooltipPlugin2Props {
|
||||
config: UPlotConfigBuilder;
|
||||
hoverMode: TooltipHoverMode;
|
||||
@@ -40,6 +42,7 @@ interface TooltipPlugin2Props {
|
||||
clientZoom?: boolean;
|
||||
|
||||
onSelectRange?: OnSelectRangeCallback;
|
||||
getDataLinks?: GeDataLinksCallback;
|
||||
|
||||
render: (
|
||||
u: uPlot,
|
||||
@@ -49,7 +52,8 @@ interface TooltipPlugin2Props {
|
||||
dismiss: () => void,
|
||||
// selected time range (for annotation triggering)
|
||||
timeRange: TimeRange2 | null,
|
||||
viaSync: boolean
|
||||
viaSync: boolean,
|
||||
dataLinks: LinkModel[]
|
||||
) => React.ReactNode;
|
||||
|
||||
maxWidth?: number;
|
||||
@@ -102,6 +106,8 @@ const MIN_ZOOM_DIST = 5;
|
||||
|
||||
const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey;
|
||||
|
||||
const getDataLinksFallback: GeDataLinksCallback = () => [];
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
@@ -115,6 +121,7 @@ export const TooltipPlugin2 = ({
|
||||
maxWidth,
|
||||
syncMode = DashboardCursorSync.Off,
|
||||
syncScope = 'global', // eventsScope
|
||||
getDataLinks = getDataLinksFallback,
|
||||
}: TooltipPlugin2Props) => {
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
const portalRoot = useRef<HTMLElement | null>(null);
|
||||
@@ -131,6 +138,9 @@ export const TooltipPlugin2 = ({
|
||||
const renderRef = useRef(render);
|
||||
renderRef.current = render;
|
||||
|
||||
const getLinksRef = useRef(getDataLinks);
|
||||
getLinksRef.current = getDataLinks;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
sizeRef.current = {
|
||||
width: 0,
|
||||
@@ -187,6 +197,7 @@ export const TooltipPlugin2 = ({
|
||||
let seriesIdxs: Array<number | null> = plot?.cursor.idxs!.slice()!;
|
||||
let closestSeriesIdx: number | null = null;
|
||||
let viaSync = false;
|
||||
let dataLinks: LinkModel[] = [];
|
||||
|
||||
let pendingRender = false;
|
||||
let pendingPinned = false;
|
||||
@@ -242,7 +253,16 @@ export const TooltipPlugin2 = ({
|
||||
isHovering: _isHovering,
|
||||
contents:
|
||||
_isHovering || selectedRange != null
|
||||
? renderRef.current(_plot!, seriesIdxs, closestSeriesIdx, _isPinned, dismiss, selectedRange, viaSync)
|
||||
? renderRef.current(
|
||||
_plot!,
|
||||
seriesIdxs,
|
||||
closestSeriesIdx,
|
||||
_isPinned,
|
||||
dismiss,
|
||||
selectedRange,
|
||||
viaSync,
|
||||
dataLinks
|
||||
)
|
||||
: null,
|
||||
dismiss,
|
||||
};
|
||||
@@ -257,6 +277,8 @@ export const TooltipPlugin2 = ({
|
||||
_isPinned = false;
|
||||
_isHovering = false;
|
||||
_plot!.setCursor({ left: -10, top: -10 });
|
||||
dataLinks = [];
|
||||
|
||||
scheduleRender(prevIsPinned);
|
||||
};
|
||||
|
||||
@@ -313,6 +335,8 @@ export const TooltipPlugin2 = ({
|
||||
}
|
||||
// only pinnable tooltip is visible *and* is within proximity to series/point
|
||||
else if (_isHovering && closestSeriesIdx != null && !_isPinned) {
|
||||
dataLinks = getLinksRef.current(closestSeriesIdx!, seriesIdxs[closestSeriesIdx!]!);
|
||||
|
||||
setTimeout(() => {
|
||||
_isPinned = true;
|
||||
scheduleRender(true);
|
||||
|
||||
@@ -478,7 +478,7 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo
|
||||
hs.log.Error("Failed to delete public dashboard")
|
||||
}
|
||||
|
||||
err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, c.SignedInUser.GetOrgID())
|
||||
err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, dash.UID, c.SignedInUser.GetOrgID())
|
||||
if err != nil {
|
||||
var dashboardErr dashboards.DashboardErr
|
||||
if ok := errors.As(err, &dashboardErr); ok {
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) {
|
||||
|
||||
dashSvc := dashboards.NewFakeDashboardService(t)
|
||||
dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dash, nil).Maybe()
|
||||
dashSvc.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
||||
dashSvc.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
||||
hs.DashboardService = dashSvc
|
||||
|
||||
hs.Cfg = setting.NewCfg()
|
||||
@@ -838,14 +838,14 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
|
||||
if dashboardService == nil {
|
||||
dashboardService, err = service.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions,
|
||||
ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil,
|
||||
ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
dashboardProvisioningService, err := service.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions,
|
||||
ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil,
|
||||
ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -478,7 +478,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
|
||||
dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl(
|
||||
sc.cfg, dashStore, folderStore,
|
||||
features, folderPermissions, dashboardPermissions, ac,
|
||||
folderServiceWithFlagOn, fStore, nil, zanzana.NewNoopClient(), nil,
|
||||
folderServiceWithFlagOn, fStore, nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(b, err)
|
||||
|
||||
|
||||
@@ -46,14 +46,20 @@ type FolderInfoList struct {
|
||||
// FolderInfo briefly describes a folder -- unlike a folder resource,
|
||||
// this is a partial record of the folder metadata used for navigating parents and children
|
||||
type FolderInfo struct {
|
||||
// UID is the unique identifier for a folder (and the k8s name)
|
||||
UID string `json:"uid"`
|
||||
// Name is the k8s name (eg, the unique identifier) for a folder
|
||||
Name string `json:"name"`
|
||||
|
||||
// Title is the display value
|
||||
Title string `json:"title"`
|
||||
|
||||
// The folder description
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// The parent folder UID
|
||||
Parent string `json:"parent,omitempty"`
|
||||
|
||||
// This folder does not resolve
|
||||
Detached bool `json:"detached,omitempty"`
|
||||
}
|
||||
|
||||
// Access control information for the current user
|
||||
|
||||
@@ -170,9 +170,9 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co
|
||||
Description: "FolderInfo briefly describes a folder -- unlike a folder resource, this is a partial record of the folder metadata used for navigating parents and children",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"uid": {
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "UID is the unique identifier for a folder (and the k8s name)",
|
||||
Description: "Name is the k8s name (eg, the unique identifier) for a folder",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
@@ -186,6 +186,13 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"description": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The folder description",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"parent": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The parent folder UID",
|
||||
@@ -193,8 +200,15 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"detached": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "This folder does not resolve",
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"uid", "title"},
|
||||
Required: []string{"name", "title"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ require (
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/sosodev/duration v1.2.0 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.11 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.20 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.4.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
|
||||
|
||||
+2
-2
@@ -221,8 +221,8 @@ github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
|
||||
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
|
||||
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8=
|
||||
github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc=
|
||||
github.com/vektah/gqlparser/v2 v2.5.20 h1:kPaWbhBntxoZPaNdBaIPT1Kh0i1b/onb5kXgEdP5JCo=
|
||||
github.com/vektah/gqlparser/v2 v2.5.20/go.mod h1:xMl+ta8a5M1Yo1A1Iwt/k7gSpscwSnHZdw7tfhEGfTM=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
||||
@@ -50,7 +50,7 @@ func OrgRedirect(cfg *setting.Cfg, userSvc user.Service) web.Handler {
|
||||
qs = fmt.Sprintf("%s&kiosk", urlParams.Encode())
|
||||
}
|
||||
|
||||
newURL := fmt.Sprintf("%s%s?%s", cfg.AppURL, strings.TrimPrefix(c.Req.URL.Path, "/"), qs)
|
||||
newURL := fmt.Sprintf("%s%s?%s", cfg.AppSubURL, strings.TrimPrefix(c.Req.URL.Path, "/"), qs)
|
||||
|
||||
c.Redirect(newURL, 302)
|
||||
}
|
||||
|
||||
@@ -126,11 +126,6 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
|
||||
|
||||
storage := map[string]rest.Storage{}
|
||||
storage[resourceInfo.StoragePath()] = legacyStore
|
||||
storage[resourceInfo.StoragePath("parents")] = &subParentsREST{b.folderSvc}
|
||||
storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc}
|
||||
storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher}
|
||||
|
||||
// enable dual writer
|
||||
if optsGetter != nil && dualWriteBuilder != nil {
|
||||
store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter)
|
||||
if err != nil {
|
||||
@@ -141,6 +136,11 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
|
||||
return err
|
||||
}
|
||||
}
|
||||
storage[resourceInfo.StoragePath("parents")] = &subParentsREST{
|
||||
getter: storage[resourceInfo.StoragePath()].(rest.Getter), // Get the parents
|
||||
}
|
||||
storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher}
|
||||
storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
|
||||
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
|
||||
|
||||
@@ -2,18 +2,20 @@ package folders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
)
|
||||
|
||||
type subParentsREST struct {
|
||||
service folder.Service
|
||||
getter rest.Getter
|
||||
}
|
||||
|
||||
var _ = rest.Connecter(&subParentsREST{})
|
||||
@@ -43,32 +45,58 @@ func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
}
|
||||
|
||||
func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folder, ok := obj.(*v0alpha1.Folder)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expecting folder, found: %T", folder)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ns, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
parents, err := r.service.GetParents(ctx, folder.GetParentsQuery{
|
||||
UID: name,
|
||||
OrgID: ns.OrgID,
|
||||
})
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
info := &v0alpha1.FolderInfoList{
|
||||
Items: make([]v0alpha1.FolderInfo, 0),
|
||||
Items: []v0alpha1.FolderInfo{},
|
||||
}
|
||||
for _, parent := range parents {
|
||||
for folder != nil {
|
||||
parent := ""
|
||||
meta, _ := utils.MetaAccessor(folder)
|
||||
if meta != nil {
|
||||
parent = meta.GetFolder()
|
||||
}
|
||||
info.Items = append(info.Items, v0alpha1.FolderInfo{
|
||||
UID: parent.UID,
|
||||
Title: parent.Title,
|
||||
Parent: parent.ParentUID,
|
||||
Name: folder.Name,
|
||||
Title: folder.Spec.Title,
|
||||
Description: folder.Spec.Description,
|
||||
Parent: parent,
|
||||
})
|
||||
if parent == "" {
|
||||
break
|
||||
}
|
||||
|
||||
obj, err = r.getter.Get(ctx, parent, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
info.Items = append(info.Items, v0alpha1.FolderInfo{
|
||||
Name: parent,
|
||||
Detached: true,
|
||||
Description: err.Error(),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
folder, ok = obj.(*v0alpha1.Folder)
|
||||
if !ok {
|
||||
info.Items = append(info.Items, v0alpha1.FolderInfo{
|
||||
Name: parent,
|
||||
Detached: true,
|
||||
Description: fmt.Sprintf("expected folder, found: %T", obj),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Start from the root
|
||||
slices.Reverse(info.Items)
|
||||
responder.Object(http.StatusOK, info)
|
||||
}), nil
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O
|
||||
var refError ErrorWithRefID
|
||||
statusCode := http.StatusBadRequest
|
||||
message := err
|
||||
refID := ""
|
||||
refID := "A"
|
||||
|
||||
if errors.Is(err, datasources.ErrDataSourceNotFound) {
|
||||
statusCode = http.StatusNotFound
|
||||
@@ -187,6 +187,11 @@ func (b *QueryAPIBuilder) execute(ctx context.Context, req parsedRequestInfo) (q
|
||||
qdr, err = b.executeConcurrentQueries(ctx, req.Requests)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
b.log.Debug("error in query phase, skipping expressions", "error", err)
|
||||
return qdr, err //return early here to prevent expressions from being executed if we got an error during the query phase
|
||||
}
|
||||
|
||||
if len(req.Expressions) > 0 {
|
||||
b.log.Debug("executing expressions")
|
||||
qdr, err = b.handleExpressions(ctx, req, qdr)
|
||||
@@ -232,7 +237,9 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d
|
||||
req.Headers,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
b.log.Debug("error getting single datasource client", "error", err, "reqUid", req.UID)
|
||||
qdr := buildErrorResponse(err, req)
|
||||
return qdr, err
|
||||
}
|
||||
|
||||
code, rsp, err := client.QueryData(ctx, *req.Request)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
//go:generate mockery --name DashboardService --structname FakeDashboardService --inpackage --filename dashboard_service_mock.go
|
||||
type DashboardService interface {
|
||||
BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, validateProvisionedDashboard bool) (*SaveDashboardCommand, error)
|
||||
DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error
|
||||
DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error
|
||||
FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error)
|
||||
// GetDashboard fetches a dashboard.
|
||||
// To fetch a dashboard under root by title should set the folder UID to point to an empty string
|
||||
|
||||
@@ -102,17 +102,17 @@ func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeleteDashboard provides a mock function with given fields: ctx, dashboardId, orgId
|
||||
func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
|
||||
ret := _m.Called(ctx, dashboardId, orgId)
|
||||
// DeleteDashboard provides a mock function with given fields: ctx, dashboardId, dashboardUID, orgId
|
||||
func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error {
|
||||
ret := _m.Called(ctx, dashboardId, dashboardUID, orgId)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteDashboard")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok {
|
||||
r0 = rf(ctx, dashboardId, orgId)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64, string, int64) error); ok {
|
||||
r0 = rf(ctx, dashboardId, dashboardUID, orgId)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/authlib/claims"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel"
|
||||
@@ -38,6 +40,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
k8sUser "k8s.io/apiserver/pkg/authentication/user"
|
||||
@@ -67,6 +70,7 @@ type DashboardServiceImpl struct {
|
||||
dashboardStore dashboards.Store
|
||||
folderStore folder.FolderStore
|
||||
folderService folder.Service
|
||||
userService user.Service
|
||||
features featuremgmt.FeatureToggles
|
||||
folderPermissions accesscontrol.FolderPermissionsService
|
||||
dashboardPermissions accesscontrol.DashboardPermissionsService
|
||||
@@ -79,6 +83,7 @@ type DashboardServiceImpl struct {
|
||||
// interface to allow for testing
|
||||
type dashboardK8sHandler interface {
|
||||
getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool)
|
||||
getNamespace(orgID int64) string
|
||||
}
|
||||
|
||||
var _ dashboardK8sHandler = (*dashk8sHandler)(nil)
|
||||
@@ -95,15 +100,10 @@ func ProvideDashboardServiceImpl(
|
||||
features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService,
|
||||
dashboardPermissionsService accesscontrol.DashboardPermissionsService, ac accesscontrol.AccessControl,
|
||||
folderSvc folder.Service, fStore folder.Store, r prometheus.Registerer, zclient zanzana.Client,
|
||||
restConfigProvider apiserver.RestConfigProvider,
|
||||
restConfigProvider apiserver.RestConfigProvider, userService user.Service,
|
||||
) (*DashboardServiceImpl, error) {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: v0alpha1.GROUP,
|
||||
Version: v0alpha1.VERSION,
|
||||
Resource: v0alpha1.DashboardResourceInfo.GetName(),
|
||||
}
|
||||
k8sHandler := &dashk8sHandler{
|
||||
gvr: gvr,
|
||||
gvr: v0alpha1.DashboardResourceInfo.GroupVersionResource(),
|
||||
namespacer: request.GetNamespaceMapper(cfg),
|
||||
restConfigProvider: restConfigProvider,
|
||||
}
|
||||
@@ -119,6 +119,7 @@ func ProvideDashboardServiceImpl(
|
||||
zclient: zclient,
|
||||
folderStore: folderStore,
|
||||
folderService: folderSvc,
|
||||
userService: userService,
|
||||
k8sclient: k8sHandler,
|
||||
metrics: newDashboardsMetrics(r),
|
||||
}
|
||||
@@ -142,6 +143,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx con
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*dashboards.DashboardProvisioning, error) {
|
||||
// TODO: make this go through the k8s cli too under the feature toggle. First get dashboard through unistore & then get provisioning data
|
||||
return dr.dashboardStore.GetProvisionedDataByDashboardUID(ctx, orgID, dashboardUID)
|
||||
}
|
||||
|
||||
@@ -284,6 +286,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error {
|
||||
// TODO: once we can search in unistore by id, go through k8s cli too
|
||||
return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd)
|
||||
}
|
||||
|
||||
@@ -358,6 +361,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
|
||||
}
|
||||
|
||||
// dashboard
|
||||
// TODO: make this go through the k8s cli too under the feature toggle. First save dashboard & then save provisioning data
|
||||
dash, err := dr.dashboardStore.SaveProvisionedDashboard(ctx, *cmd, provisioning)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -402,9 +406,9 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dash, err := dr.dashboardStore.SaveDashboard(ctx, *cmd)
|
||||
dash, err := dr.saveDashboard(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("saving dashboard failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// new dashboard created
|
||||
@@ -414,7 +418,20 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar
|
||||
|
||||
return dash, nil
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) saveDashboard(ctx context.Context, cmd *dashboards.SaveDashboardCommand) (*dashboards.Dashboard, error) {
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
|
||||
return dr.saveDashboardThroughK8s(ctx, cmd, cmd.OrgID)
|
||||
}
|
||||
|
||||
return dr.dashboardStore.SaveDashboard(ctx, *cmd)
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) {
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
|
||||
return dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: orgID, UID: uid, IncludeDeleted: true})
|
||||
}
|
||||
|
||||
return dr.dashboardStore.GetSoftDeletedDashboard(ctx, orgID, uid)
|
||||
}
|
||||
|
||||
@@ -457,6 +474,8 @@ func (dr *DashboardServiceImpl) RestoreDashboard(ctx context.Context, dashboard
|
||||
return folder.ErrInternal.Errorf("failed to fetch parent folder from store: %w", err)
|
||||
}
|
||||
|
||||
// TODO: once restore in k8s is finalized, add functionality here under the feature toggle
|
||||
|
||||
return dr.dashboardStore.RestoreDashboard(ctx, dashboard.OrgID, dashboard.UID, restoringFolder)
|
||||
}
|
||||
|
||||
@@ -473,13 +492,18 @@ func (dr *DashboardServiceImpl) SoftDeleteDashboard(ctx context.Context, orgID i
|
||||
return dashboards.ErrDashboardCannotDeleteProvisionedDashboard
|
||||
}
|
||||
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
|
||||
// deletes in unistore are soft deletes, so we can just delete in the same way
|
||||
return dr.deleteDashboardThroughK8s(ctx, &dashboards.DeleteDashboardCommand{OrgID: orgID, UID: dashboardUID})
|
||||
}
|
||||
|
||||
return dr.dashboardStore.SoftDeleteDashboard(ctx, orgID, dashboardUID)
|
||||
}
|
||||
|
||||
// DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for
|
||||
// operations by the user where we want to make sure user does not delete provisioned dashboard.
|
||||
func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
|
||||
return dr.deleteDashboard(ctx, dashboardId, orgId, true)
|
||||
func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error {
|
||||
return dr.deleteDashboard(ctx, dashboardId, dashboardUID, orgId, true)
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, dashboardPublicUid string) (*dashboards.Dashboard, error) {
|
||||
@@ -488,10 +512,10 @@ func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, das
|
||||
|
||||
// DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned.
|
||||
func (dr *DashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
|
||||
return dr.deleteDashboard(ctx, dashboardId, orgId, false)
|
||||
return dr.deleteDashboard(ctx, dashboardId, "", orgId, false)
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, orgId int64, validateProvisionedDashboard bool) error {
|
||||
func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64, validateProvisionedDashboard bool) error {
|
||||
ctx, span := tracer.Start(ctx, "dashboards.service.deleteDashboard")
|
||||
defer span.End()
|
||||
|
||||
@@ -505,7 +529,14 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId
|
||||
return dashboards.ErrDashboardCannotDeleteProvisionedDashboard
|
||||
}
|
||||
}
|
||||
cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId}
|
||||
|
||||
cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId, UID: dashboardUID}
|
||||
|
||||
// TODO: once we can do this search by IDs in unistore, remove this constraint
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) && cmd.UID != "" {
|
||||
return dr.deleteDashboardThroughK8s(ctx, cmd)
|
||||
}
|
||||
|
||||
return dr.dashboardStore.DeleteDashboard(ctx, cmd)
|
||||
}
|
||||
|
||||
@@ -526,7 +557,7 @@ func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashbo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dash, err := dr.dashboardStore.SaveDashboard(ctx, *cmd)
|
||||
dash, err := dr.saveDashboard(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -539,10 +570,12 @@ func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashbo
|
||||
// UnprovisionDashboard removes info about dashboard being provisioned. Used after provisioning configs are changed
|
||||
// and provisioned dashboards are left behind but not deleted.
|
||||
func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error {
|
||||
// TODO: once we can search by dashboard ID in unistore, go through k8s cli too
|
||||
return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId)
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) {
|
||||
// TODO: once we can do this search in unistore, go through k8s cli too
|
||||
return dr.dashboardStore.GetDashboardsByPluginID(ctx, query)
|
||||
}
|
||||
|
||||
@@ -626,92 +659,20 @@ func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
func (dk8s *dashk8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) {
|
||||
dyn, err := dynamic.NewForConfig(dk8s.restConfigProvider.GetRestConfig(ctx))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return dyn.Resource(dk8s.gvr).Namespace(dk8s.namespacer(orgID)), true
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) getK8sContext(ctx context.Context) (context.Context, context.CancelFunc, error) {
|
||||
requester, requesterErr := identity.GetRequester(ctx)
|
||||
if requesterErr != nil {
|
||||
return nil, nil, requesterErr
|
||||
}
|
||||
|
||||
user, exists := k8sRequest.UserFrom(ctx)
|
||||
if !exists {
|
||||
// add in k8s user if not there yet
|
||||
var ok bool
|
||||
user, ok = requester.(k8sUser.Info)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("could not convert user to k8s user")
|
||||
}
|
||||
}
|
||||
|
||||
newCtx := k8sRequest.WithUser(context.Background(), user)
|
||||
newCtx = log.WithContextualAttributes(newCtx, log.FromContext(ctx))
|
||||
// TODO: after GLSA token workflow is removed, make this return early
|
||||
// and move the else below to be unconditional
|
||||
if requesterErr == nil {
|
||||
newCtxWithRequester := identity.WithRequester(newCtx, requester)
|
||||
newCtx = newCtxWithRequester
|
||||
}
|
||||
|
||||
// inherit the deadline from the original context, if it exists
|
||||
deadline, ok := ctx.Deadline()
|
||||
if ok {
|
||||
var newCancel context.CancelFunc
|
||||
newCtx, newCancel = context.WithTimeout(newCtx, time.Until(deadline))
|
||||
return newCtx, newCancel, nil
|
||||
}
|
||||
|
||||
return newCtx, nil, nil
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) {
|
||||
// TODO: once getting dashboards by ID in unified storage is supported, we can remove the restraint of the uid being provided
|
||||
// TODO: once we can do this search by ID in unistore, remove this constraint
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) && query.UID != "" {
|
||||
// create a new context - prevents issues when the request stems from the k8s api itself
|
||||
// otherwise the context goes through the handlers twice and causes issues
|
||||
newCtx, cancel, err := dr.getK8sContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := dr.k8sclient.getClient(newCtx, query.OrgID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// if including deleted dashboards, use the /latest subresource
|
||||
subresource := ""
|
||||
if query.IncludeDeleted {
|
||||
subresource = "latest"
|
||||
}
|
||||
|
||||
out, err := client.Get(newCtx, query.UID, v1.GetOptions{}, subresource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if out == nil {
|
||||
return nil, dashboards.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
return UnstructuredToLegacyDashboard(out, query.OrgID)
|
||||
return dr.getDashboardThroughK8s(ctx, query)
|
||||
}
|
||||
|
||||
return dr.dashboardStore.GetDashboard(ctx, query)
|
||||
}
|
||||
|
||||
// TODO: once getting dashboards by ID in unified storage is supported, need to do the same as the above function
|
||||
// TODO: once we can do this search by ID in unistore, go through k8s cli too
|
||||
func (dr *DashboardServiceImpl) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) {
|
||||
return dr.dashboardStore.GetDashboardUIDByID(ctx, query)
|
||||
}
|
||||
|
||||
// TODO: add support to get dashboards in unified storage.
|
||||
func (dr *DashboardServiceImpl) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) {
|
||||
return dr.dashboardStore.GetDashboards(ctx, query)
|
||||
}
|
||||
@@ -808,7 +769,7 @@ func (dr *DashboardServiceImpl) getUserSharedDashboardUIDs(ctx context.Context,
|
||||
return userDashboardUIDs, nil
|
||||
}
|
||||
|
||||
// TODO: add support to find dashboards in unified storage too.
|
||||
// TODO: once we can do this search by this in unistore, go through k8s cli too
|
||||
func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
|
||||
ctx, span := tracer.Start(ctx, "dashboards.service.FindDashboards")
|
||||
defer span.End()
|
||||
@@ -833,6 +794,7 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
|
||||
return dr.dashboardStore.FindDashboards(ctx, query)
|
||||
}
|
||||
|
||||
// TODO: once we can do this search in unistore, go through k8s cli too
|
||||
func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (model.HitList, error) {
|
||||
ctx, span := tracer.Start(ctx, "dashboards.service.SearchDashboards")
|
||||
defer span.End()
|
||||
@@ -854,6 +816,14 @@ func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *das
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) GetAllDashboards(ctx context.Context) ([]*dashboards.Dashboard, error) {
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dr.listDashboardThroughK8s(ctx, requester.GetOrgID())
|
||||
}
|
||||
|
||||
return dr.dashboardStore.GetAllDashboards(ctx)
|
||||
}
|
||||
|
||||
@@ -915,6 +885,7 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) {
|
||||
// TODO: use k8s client to get dashboards first, and then filter tags and join
|
||||
return dr.dashboardStore.GetDashboardTags(ctx, query)
|
||||
}
|
||||
|
||||
@@ -945,7 +916,7 @@ func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (i
|
||||
return 0, err
|
||||
}
|
||||
for _, dashboard := range deletedDashboards {
|
||||
err = dr.DeleteDashboard(ctx, dashboard.ID, dashboard.OrgID)
|
||||
err = dr.DeleteDashboard(ctx, dashboard.ID, dashboard.UID, dashboard.OrgID)
|
||||
if err != nil {
|
||||
dr.log.Warn("Failed to cleanup deleted dashboard", "dashboardUid", dashboard.UID, "error", err)
|
||||
break
|
||||
@@ -956,35 +927,247 @@ func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (i
|
||||
return deletedDashboardsCount, nil
|
||||
}
|
||||
|
||||
func UnstructuredToLegacyDashboard(item *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) {
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Dashboard k8s functions
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
func (dk8s *dashk8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) {
|
||||
dyn, err := dynamic.NewForConfig(dk8s.restConfigProvider.GetRestConfig(ctx))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return dyn.Resource(dk8s.gvr).Namespace(dk8s.getNamespace(orgID)), true
|
||||
}
|
||||
|
||||
func (dk8s *dashk8sHandler) getNamespace(orgID int64) string {
|
||||
return dk8s.namespacer(orgID)
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) getK8sContext(ctx context.Context) (context.Context, context.CancelFunc, error) {
|
||||
requester, requesterErr := identity.GetRequester(ctx)
|
||||
if requesterErr != nil {
|
||||
return nil, nil, requesterErr
|
||||
}
|
||||
|
||||
user, exists := k8sRequest.UserFrom(ctx)
|
||||
if !exists {
|
||||
// add in k8s user if not there yet
|
||||
var ok bool
|
||||
user, ok = requester.(k8sUser.Info)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("could not convert user to k8s user")
|
||||
}
|
||||
}
|
||||
|
||||
newCtx := k8sRequest.WithUser(context.Background(), user)
|
||||
newCtx = log.WithContextualAttributes(newCtx, log.FromContext(ctx))
|
||||
// TODO: after GLSA token workflow is removed, make this return early
|
||||
// and move the else below to be unconditional
|
||||
if requesterErr == nil {
|
||||
newCtxWithRequester := identity.WithRequester(newCtx, requester)
|
||||
newCtx = newCtxWithRequester
|
||||
}
|
||||
|
||||
// inherit the deadline from the original context, if it exists
|
||||
deadline, ok := ctx.Deadline()
|
||||
if ok {
|
||||
var newCancel context.CancelFunc
|
||||
newCtx, newCancel = context.WithTimeout(newCtx, time.Until(deadline))
|
||||
return newCtx, newCancel, nil
|
||||
}
|
||||
|
||||
return newCtx, nil, nil
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) {
|
||||
// create a new context - prevents issues when the request stems from the k8s api itself
|
||||
// otherwise the context goes through the handlers twice and causes issues
|
||||
newCtx, cancel, err := dr.getK8sContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := dr.k8sclient.getClient(newCtx, query.OrgID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// if including deleted dashboards, use the /latest subresource
|
||||
subresource := ""
|
||||
if query.IncludeDeleted {
|
||||
subresource = "latest"
|
||||
}
|
||||
|
||||
out, err := client.Get(newCtx, query.UID, v1.GetOptions{}, subresource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if out == nil {
|
||||
return nil, dashboards.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
return dr.UnstructuredToLegacyDashboard(ctx, out, query.OrgID)
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd *dashboards.SaveDashboardCommand, orgID int64) (*dashboards.Dashboard, error) {
|
||||
// create a new context - prevents issues when the request stems from the k8s api itself
|
||||
// otherwise the context goes through the handlers twice and causes issues
|
||||
newCtx, cancel, err := dr.getK8sContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := dr.k8sclient.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
obj, err := LegacySaveCommandToUnstructured(cmd, dr.k8sclient.getNamespace(orgID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out *unstructured.Unstructured
|
||||
current, err := client.Get(newCtx, obj.GetName(), v1.GetOptions{})
|
||||
if current == nil || err != nil {
|
||||
out, err = client.Create(newCtx, &obj, v1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
out, err = client.Update(newCtx, &obj, v1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
finalDash, err := dr.UnstructuredToLegacyDashboard(ctx, out, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return finalDash, nil
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) deleteDashboardThroughK8s(ctx context.Context, cmd *dashboards.DeleteDashboardCommand) error {
|
||||
// create a new context - prevents issues when the request stems from the k8s api itself
|
||||
// otherwise the context goes through the handlers twice and causes issues
|
||||
newCtx, cancel, err := dr.getK8sContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := dr.k8sclient.getClient(newCtx, cmd.OrgID)
|
||||
if !ok {
|
||||
return fmt.Errorf("could not get k8s client")
|
||||
}
|
||||
|
||||
err = client.Delete(newCtx, cmd.UID, v1.DeleteOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) listDashboardThroughK8s(ctx context.Context, orgID int64) ([]*dashboards.Dashboard, error) {
|
||||
// create a new context - prevents issues when the request stems from the k8s api itself
|
||||
// otherwise the context goes through the handlers twice and causes issues
|
||||
newCtx, cancel, err := dr.getK8sContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := dr.k8sclient.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TODO: once we can do this search in unistore, update this
|
||||
out, err := client.List(newCtx, v1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if out == nil {
|
||||
return nil, dashboards.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
dashboards := make([]*dashboards.Dashboard, 0)
|
||||
for _, item := range out.Items {
|
||||
dash, err := dr.UnstructuredToLegacyDashboard(ctx, &item, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dashboards = append(dashboards, dash)
|
||||
}
|
||||
|
||||
return dashboards, nil
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) {
|
||||
spec, ok := item.Object["spec"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("error parsing dashboard from k8s response")
|
||||
}
|
||||
|
||||
out := dashboards.Dashboard{
|
||||
OrgID: orgID,
|
||||
Data: simplejson.NewFromAny(spec),
|
||||
}
|
||||
obj, err := utils.MetaAccessor(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid := obj.GetName()
|
||||
spec["uid"] = uid
|
||||
|
||||
out.UID = obj.GetName()
|
||||
out.Slug = obj.GetSlug()
|
||||
out.FolderUID = obj.GetFolder()
|
||||
dashVersion := 0
|
||||
if version, ok := spec["version"].(int64); ok {
|
||||
dashVersion = int(version)
|
||||
}
|
||||
|
||||
out := dashboards.Dashboard{
|
||||
OrgID: orgID,
|
||||
UID: uid,
|
||||
Slug: obj.GetSlug(),
|
||||
FolderUID: obj.GetFolder(),
|
||||
Version: dashVersion,
|
||||
Data: simplejson.NewFromAny(spec),
|
||||
}
|
||||
|
||||
out.Created = obj.GetCreationTimestamp().Time
|
||||
updated, err := obj.GetUpdatedTimestamp()
|
||||
if err == nil && updated != nil {
|
||||
out.Updated = *updated
|
||||
} else {
|
||||
// by default, set updated to created
|
||||
out.Updated = out.Created
|
||||
}
|
||||
|
||||
deleted := obj.GetDeletionTimestamp()
|
||||
if deleted != nil {
|
||||
out.Deleted = obj.GetDeletionTimestamp().Time
|
||||
}
|
||||
|
||||
createdBy := obj.GetCreatedBy()
|
||||
if createdBy != "" && toUID(createdBy) != "" {
|
||||
creator, err := dr.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: toUID(createdBy)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.CreatedBy = creator.ID
|
||||
}
|
||||
|
||||
updatedBy := obj.GetUpdatedBy()
|
||||
if updatedBy != "" && toUID(updatedBy) != "" {
|
||||
updator, err := dr.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: toUID(updatedBy)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.UpdatedBy = updator.ID
|
||||
}
|
||||
|
||||
if id, ok := spec["id"].(int64); ok {
|
||||
out.ID = id
|
||||
}
|
||||
@@ -993,10 +1176,6 @@ func UnstructuredToLegacyDashboard(item *unstructured.Unstructured, orgID int64)
|
||||
out.GnetID = gnetID
|
||||
}
|
||||
|
||||
if version, ok := spec["version"].(int64); ok {
|
||||
out.Version = int(version)
|
||||
}
|
||||
|
||||
if pluginID, ok := spec["plugin_id"].(string); ok {
|
||||
out.PluginID = pluginID
|
||||
}
|
||||
@@ -1019,3 +1198,48 @@ func UnstructuredToLegacyDashboard(item *unstructured.Unstructured, orgID int64)
|
||||
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, namespace string) (unstructured.Unstructured, error) {
|
||||
uid := cmd.GetDashboardModel().UID
|
||||
if uid == "" {
|
||||
uid = uuid.NewString()
|
||||
}
|
||||
|
||||
finalObj := unstructured.Unstructured{
|
||||
Object: map[string]interface{}{},
|
||||
}
|
||||
|
||||
obj := map[string]interface{}{}
|
||||
body, err := cmd.Dashboard.ToDB()
|
||||
if err != nil {
|
||||
return finalObj, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &obj)
|
||||
if err != nil {
|
||||
return finalObj, err
|
||||
}
|
||||
|
||||
// update the version
|
||||
version, ok := obj["version"].(float64)
|
||||
if !ok || version == 0 {
|
||||
obj["version"] = 1
|
||||
} else if !cmd.Overwrite {
|
||||
obj["version"] = version + 1
|
||||
}
|
||||
|
||||
finalObj.Object["spec"] = obj
|
||||
finalObj.SetName(uid)
|
||||
finalObj.SetNamespace(namespace)
|
||||
finalObj.SetGroupVersionKind(v0alpha1.DashboardResourceInfo.GroupVersionKind())
|
||||
|
||||
return finalObj, nil
|
||||
}
|
||||
|
||||
func toUID(rawIdentifier string) string {
|
||||
parts := strings.Split(rawIdentifier, ":")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
@@ -886,6 +886,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc
|
||||
nil,
|
||||
zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashboardService)
|
||||
@@ -954,6 +955,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt
|
||||
nil,
|
||||
zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
res, err := service.SaveDashboard(context.Background(), &dto, false)
|
||||
@@ -981,6 +983,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto
|
||||
nil,
|
||||
zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = service.SaveDashboard(context.Background(), &dto, false)
|
||||
@@ -1027,6 +1030,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string
|
||||
nil,
|
||||
zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
res, err := service.SaveDashboard(context.Background(), &dto, false)
|
||||
@@ -1080,6 +1084,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da
|
||||
nil,
|
||||
zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
res, err := service.SaveDashboard(context.Background(), &dto, false)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -179,7 +182,7 @@ func TestDashboardService(t *testing.T) {
|
||||
|
||||
t.Run("DeleteDashboard should fail to delete it when provisioning information is missing", func(t *testing.T) {
|
||||
fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(&dashboards.DashboardProvisioning{}, nil).Once()
|
||||
err := service.DeleteDashboard(context.Background(), 1, 1)
|
||||
err := service.DeleteDashboard(context.Background(), 1, "", 1)
|
||||
require.Equal(t, err, dashboards.ErrDashboardCannotDeleteProvisionedDashboard)
|
||||
})
|
||||
})
|
||||
@@ -196,7 +199,7 @@ func TestDashboardService(t *testing.T) {
|
||||
args := &dashboards.DeleteDashboardCommand{OrgID: 1, ID: 1}
|
||||
fakeStore.On("DeleteDashboard", mock.Anything, args).Return(nil).Once()
|
||||
fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(nil, nil).Once()
|
||||
err := service.DeleteDashboard(context.Background(), 1, 1)
|
||||
err := service.DeleteDashboard(context.Background(), 1, "", 1)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
})
|
||||
@@ -238,6 +241,10 @@ func (m *mockDashK8sCli) getClient(ctx context.Context, orgID int64) (dynamic.Re
|
||||
return args.Get(0).(dynamic.ResourceInterface), args.Bool(1)
|
||||
}
|
||||
|
||||
func (m *mockDashK8sCli) getNamespace(orgID int64) string {
|
||||
return "default"
|
||||
}
|
||||
|
||||
type mockResourceInterface struct {
|
||||
mock.Mock
|
||||
dynamic.ResourceInterface
|
||||
@@ -251,6 +258,48 @@ func (m *mockResourceInterface) Get(ctx context.Context, name string, options me
|
||||
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*unstructured.UnstructuredList), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
args := m.Called(ctx, obj, options, subresources)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
args := m.Called(ctx, obj, options, subresources)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error {
|
||||
args := m.Called(ctx, name, options, subresources)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func setupK8sDashboardTests(service *DashboardServiceImpl) (context.Context, *mockDashK8sCli, *mockResourceInterface) {
|
||||
k8sClientMock := new(mockDashK8sCli)
|
||||
k8sResourceMock := new(mockResourceInterface)
|
||||
service.k8sclient = k8sClientMock
|
||||
service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards)
|
||||
|
||||
ctx := context.Background()
|
||||
userCtx := &user.SignedInUser{UserID: 1, OrgID: 1}
|
||||
ctx = identity.WithRequester(ctx, userCtx)
|
||||
|
||||
return ctx, k8sClientMock, k8sResourceMock
|
||||
}
|
||||
|
||||
func TestGetDashboard(t *testing.T) {
|
||||
fakeStore := dashboards.FakeDashboardStore{}
|
||||
defer fakeStore.AssertExpectations(t)
|
||||
@@ -258,14 +307,13 @@ func TestGetDashboard(t *testing.T) {
|
||||
cfg: setting.NewCfg(),
|
||||
dashboardStore: &fakeStore,
|
||||
}
|
||||
query := &dashboards.GetDashboardQuery{
|
||||
UID: "test-uid",
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
|
||||
service.features = featuremgmt.WithFeatures()
|
||||
query := &dashboards.GetDashboardQuery{
|
||||
UID: "test-uid",
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
fakeStore.On("GetDashboard", mock.Anything, query).Return(&dashboards.Dashboard{}, nil).Once()
|
||||
dashboard, err := service.GetDashboard(context.Background(), query)
|
||||
require.NoError(t, err)
|
||||
@@ -274,36 +322,26 @@ func TestGetDashboard(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
|
||||
k8sClientMock := new(mockDashK8sCli)
|
||||
k8sResourceMock := new(mockResourceInterface)
|
||||
service.k8sclient = k8sClientMock
|
||||
service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards)
|
||||
query := &dashboards.GetDashboardQuery{
|
||||
UID: "test-uid",
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service)
|
||||
dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"test": "test",
|
||||
"title": "testing slugify",
|
||||
"test": "test",
|
||||
"version": int64(1),
|
||||
"title": "testing slugify",
|
||||
},
|
||||
}}
|
||||
|
||||
dashboardExpected := dashboards.Dashboard{
|
||||
UID: "uid", // uid is the name of the k8s object
|
||||
Title: "testing slugify",
|
||||
Slug: "testing-slugify", // slug is taken from title
|
||||
OrgID: 1, // orgID is populated from the query
|
||||
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify"}),
|
||||
UID: "uid", // uid is the name of the k8s object
|
||||
Title: "testing slugify",
|
||||
Slug: "testing-slugify", // slug is taken from title
|
||||
OrgID: 1, // orgID is populated from the query
|
||||
Version: 1,
|
||||
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
userCtx := &user.SignedInUser{UserID: 1}
|
||||
ctx = identity.WithRequester(ctx, userCtx)
|
||||
k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once()
|
||||
k8sResourceMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once()
|
||||
|
||||
@@ -316,18 +354,7 @@ func TestGetDashboard(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Should return error when Kubernetes client fails", func(t *testing.T) {
|
||||
k8sClientMock := new(mockDashK8sCli)
|
||||
k8sResourceMock := new(mockResourceInterface)
|
||||
service.k8sclient = k8sClientMock
|
||||
service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards)
|
||||
query := &dashboards.GetDashboardQuery{
|
||||
UID: "test-uid",
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
userCtx := &user.SignedInUser{UserID: 1}
|
||||
ctx = identity.WithRequester(ctx, userCtx)
|
||||
ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service)
|
||||
k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once()
|
||||
k8sResourceMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, assert.AnError).Once()
|
||||
|
||||
@@ -338,21 +365,9 @@ func TestGetDashboard(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Should return dashboard not found if Kubernetes client returns nil", func(t *testing.T) {
|
||||
k8sClientMock := new(mockDashK8sCli)
|
||||
k8sResourceMock := new(mockResourceInterface)
|
||||
service.k8sclient = k8sClientMock
|
||||
service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards)
|
||||
query := &dashboards.GetDashboardQuery{
|
||||
UID: "test-uid",
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
userCtx := &user.SignedInUser{UserID: 1}
|
||||
ctx = identity.WithRequester(ctx, userCtx)
|
||||
ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service)
|
||||
k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once()
|
||||
k8sResourceMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, nil).Once()
|
||||
|
||||
dashboard, err := service.GetDashboard(ctx, query)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, dashboards.ErrDashboardNotFound, err)
|
||||
@@ -360,3 +375,249 @@ func TestGetDashboard(t *testing.T) {
|
||||
k8sClientMock.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAllDashboards(t *testing.T) {
|
||||
fakeStore := dashboards.FakeDashboardStore{}
|
||||
defer fakeStore.AssertExpectations(t)
|
||||
service := &DashboardServiceImpl{
|
||||
cfg: setting.NewCfg(),
|
||||
dashboardStore: &fakeStore,
|
||||
}
|
||||
|
||||
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
|
||||
service.features = featuremgmt.WithFeatures()
|
||||
fakeStore.On("GetAllDashboards", mock.Anything).Return([]*dashboards.Dashboard{}, nil).Once()
|
||||
dashboard, err := service.GetAllDashboards(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dashboard)
|
||||
fakeStore.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
|
||||
ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service)
|
||||
|
||||
dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"test": "test",
|
||||
"version": int64(1),
|
||||
"title": "testing slugify",
|
||||
},
|
||||
}}
|
||||
|
||||
dashboardExpected := dashboards.Dashboard{
|
||||
UID: "uid", // uid is the name of the k8s object
|
||||
Title: "testing slugify",
|
||||
Slug: "testing-slugify", // slug is taken from title
|
||||
OrgID: 1, // orgID is populated from the query
|
||||
Version: 1, // default to version 1
|
||||
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}),
|
||||
}
|
||||
|
||||
k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once()
|
||||
k8sResourceMock.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once()
|
||||
|
||||
dashes, err := service.GetAllDashboards(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dashes)
|
||||
k8sClientMock.AssertExpectations(t)
|
||||
// make sure the conversion is working
|
||||
require.True(t, reflect.DeepEqual(dashes, []*dashboards.Dashboard{&dashboardExpected}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveDashboard(t *testing.T) {
|
||||
fakeStore := dashboards.FakeDashboardStore{}
|
||||
defer fakeStore.AssertExpectations(t)
|
||||
service := &DashboardServiceImpl{
|
||||
cfg: setting.NewCfg(),
|
||||
dashboardStore: &fakeStore,
|
||||
}
|
||||
|
||||
origNewDashboardGuardian := guardian.New
|
||||
defer func() { guardian.New = origNewDashboardGuardian }()
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
|
||||
|
||||
query := &dashboards.SaveDashboardDTO{
|
||||
OrgID: 1,
|
||||
User: &user.SignedInUser{UserID: 1},
|
||||
Dashboard: &dashboards.Dashboard{
|
||||
UID: "uid", // uid is the name of the k8s object
|
||||
Title: "testing slugify",
|
||||
Slug: "testing-slugify", // slug is taken from title
|
||||
OrgID: 1, // orgID is populated from the query
|
||||
Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid"}),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
|
||||
service.features = featuremgmt.WithFeatures()
|
||||
fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil)
|
||||
fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything, mock.AnythingOfType("bool")).Return(true, nil)
|
||||
fakeStore.On("SaveDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil)
|
||||
dashboard, err := service.SaveDashboard(context.Background(), query, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dashboard)
|
||||
fakeStore.AssertExpectations(t)
|
||||
})
|
||||
|
||||
dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"test": "test",
|
||||
"version": int64(1),
|
||||
"title": "testing slugify",
|
||||
},
|
||||
}}
|
||||
|
||||
t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) {
|
||||
ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service)
|
||||
k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true)
|
||||
k8sResourceMock.On("Get", mock.Anything, query.Dashboard.UID, mock.Anything, mock.Anything).Return(nil, nil)
|
||||
k8sResourceMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
|
||||
|
||||
dashboard, err := service.SaveDashboard(ctx, query, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dashboard)
|
||||
})
|
||||
|
||||
t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) {
|
||||
ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service)
|
||||
k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true)
|
||||
k8sResourceMock.On("Get", mock.Anything, query.Dashboard.UID, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
|
||||
k8sResourceMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
|
||||
|
||||
dashboard, err := service.SaveDashboard(ctx, query, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dashboard)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteDashboard(t *testing.T) {
|
||||
fakeStore := dashboards.FakeDashboardStore{}
|
||||
defer fakeStore.AssertExpectations(t)
|
||||
service := &DashboardServiceImpl{
|
||||
cfg: setting.NewCfg(),
|
||||
dashboardStore: &fakeStore,
|
||||
}
|
||||
|
||||
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
|
||||
service.features = featuremgmt.WithFeatures()
|
||||
fakeStore.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil).Once()
|
||||
err := service.DeleteDashboard(context.Background(), 1, "uid", 1)
|
||||
require.NoError(t, err)
|
||||
fakeStore.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
|
||||
ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service)
|
||||
k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once()
|
||||
fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil).Once()
|
||||
k8sResourceMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
|
||||
err := service.DeleteDashboard(ctx, 1, "uid", 1)
|
||||
require.NoError(t, err)
|
||||
k8sClientMock.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnstructuredToLegacyDashboard(t *testing.T) {
|
||||
fake := usertest.NewUserServiceFake()
|
||||
fake.ExpectedUser = &user.User{ID: 10, UID: "useruid"}
|
||||
dr := &DashboardServiceImpl{
|
||||
userService: fake,
|
||||
}
|
||||
t.Run("successfully converts unstructured to legacy dashboard", func(t *testing.T) {
|
||||
uid := "36b7c825-79cc-435e-acf6-c78bd96a4510"
|
||||
orgID := int64(123)
|
||||
title := "Test Dashboard"
|
||||
now := metav1.Now()
|
||||
item := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"title": title,
|
||||
"version": int64(1),
|
||||
"id": int64(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
obj, err := utils.MetaAccessor(item)
|
||||
require.NoError(t, err)
|
||||
obj.SetCreationTimestamp(now)
|
||||
obj.SetName(uid)
|
||||
obj.SetCreatedBy("user:useruid")
|
||||
obj.SetUpdatedBy("user:useruid")
|
||||
|
||||
result, err := dr.UnstructuredToLegacyDashboard(context.Background(), item, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, uid, result.UID)
|
||||
assert.Equal(t, title, result.Title)
|
||||
assert.Equal(t, orgID, result.OrgID)
|
||||
assert.Equal(t, "test-dashboard", result.Slug) // should slugify the title
|
||||
assert.Equal(t, false, result.HasACL)
|
||||
assert.Equal(t, false, result.IsFolder)
|
||||
assert.Equal(t, int64(1), result.ID)
|
||||
assert.Equal(t, now.Time.Format(time.RFC3339), result.Created.Format(time.RFC3339))
|
||||
assert.Equal(t, int64(10), result.CreatedBy)
|
||||
assert.Equal(t, now.Time.Format(time.RFC3339), result.Updated.Format(time.RFC3339)) // updated should default to created
|
||||
assert.Equal(t, int64(10), result.UpdatedBy)
|
||||
})
|
||||
|
||||
t.Run("returns error if spec is missing", func(t *testing.T) {
|
||||
item := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{},
|
||||
}
|
||||
_, err := (&DashboardServiceImpl{}).UnstructuredToLegacyDashboard(context.Background(), item, int64(123))
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "error parsing dashboard from k8s response", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLegacySaveCommandToUnstructured(t *testing.T) {
|
||||
namespace := "test-namespace"
|
||||
t.Run("successfully converts save command to unstructured", func(t *testing.T) {
|
||||
cmd := &dashboards.SaveDashboardCommand{
|
||||
Dashboard: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "test-uid"}),
|
||||
}
|
||||
|
||||
result, err := LegacySaveCommandToUnstructured(cmd, namespace)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "test-uid", result.GetName())
|
||||
assert.Equal(t, "test-namespace", result.GetNamespace())
|
||||
spec := result.Object["spec"].(map[string]any)
|
||||
assert.Equal(t, spec["version"], 1)
|
||||
})
|
||||
|
||||
t.Run("should increase version when called", func(t *testing.T) {
|
||||
cmd := &dashboards.SaveDashboardCommand{
|
||||
Dashboard: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "test-uid", "version": int64(1)}),
|
||||
}
|
||||
result, err := LegacySaveCommandToUnstructured(cmd, namespace)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
spec := result.Object["spec"].(map[string]any)
|
||||
assert.Equal(t, spec["version"], float64(2))
|
||||
})
|
||||
}
|
||||
|
||||
func TestToUID(t *testing.T) {
|
||||
t.Run("parses valid UID", func(t *testing.T) {
|
||||
rawIdentifier := "user:uid-value"
|
||||
result := toUID(rawIdentifier)
|
||||
assert.Equal(t, "uid-value", result)
|
||||
})
|
||||
|
||||
t.Run("returns empty string for invalid identifier", func(t *testing.T) {
|
||||
rawIdentifier := "invalid-uid"
|
||||
result := toUID(rawIdentifier)
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
}
|
||||
@@ -74,6 +74,7 @@ func TestIntegrationDashboardServiceZanzana(t *testing.T) {
|
||||
nil,
|
||||
zclient,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestValidateDashboardExists(t *testing.T) {
|
||||
feats := featuremgmt.WithFeatures()
|
||||
dashboardStore, err := dashdb.ProvideDashboardStore(sqlStore, cfg, feats, tagimpl.ProvideService(sqlStore), quotatest.New(false, nil))
|
||||
require.NoError(t, err)
|
||||
dashSvc, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashboardStore, folderimpl.ProvideDashboardFolderStore(sqlStore), feats, nil, nil, acmock.New(), foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil)
|
||||
dashSvc, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashboardStore, folderimpl.ProvideDashboardFolderStore(sqlStore), feats, nil, nil, acmock.New(), foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
s := ProvideService(dsStore, secretsService, dashSvc)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -1662,6 +1662,13 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "useV2DashboardsAPI",
|
||||
Description: "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
RequiresRestart: true, // changes the API routing
|
||||
},
|
||||
{
|
||||
Name: "feedbackButton",
|
||||
Description: "Enables a button to send feedback from the Grafana UI",
|
||||
|
||||
@@ -221,6 +221,7 @@ reportingUseRawTimeRange,preview,@grafana/sharing-squad,false,false,false
|
||||
alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
|
||||
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
|
||||
alertingNotificationsStepMode,experimental,@grafana/alerting-squad,false,false,true
|
||||
useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false
|
||||
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false
|
||||
unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
|
||||
|
@@ -895,6 +895,10 @@ const (
|
||||
// Enables simplified step mode in the notifications section
|
||||
FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode"
|
||||
|
||||
// FlagUseV2DashboardsAPI
|
||||
// Use the v2 kubernetes API in the frontend for dashboards
|
||||
FlagUseV2DashboardsAPI = "useV2DashboardsAPI"
|
||||
|
||||
// FlagFeedbackButton
|
||||
// Enables a button to send feedback from the Grafana UI
|
||||
FlagFeedbackButton = "feedbackButton"
|
||||
|
||||
@@ -3620,6 +3620,19 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "useV2DashboardsAPI",
|
||||
"resourceVersion": "1732535420861",
|
||||
"creationTimestamp": "2024-11-25T11:50:20Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad",
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "userStorageAPI",
|
||||
|
||||
@@ -490,7 +490,7 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
CanEditValue: true,
|
||||
})
|
||||
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil, zanzana.NewNoopClient(), nil)
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil, zanzana.NewNoopClient(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv, ac, b)
|
||||
@@ -573,7 +573,7 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
})
|
||||
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOff,
|
||||
folderPermissions, dashboardPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, zanzana.NewNoopClient(), nil)
|
||||
folderPermissions, dashboardPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, zanzana.NewNoopClient(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b)
|
||||
@@ -719,7 +719,7 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
tc.service.dashboardStore = dashStore
|
||||
tc.service.store = nestedFolderStore
|
||||
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, tc.service.store, nil, zanzana.NewNoopClient(), nil)
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, tc.service.store, nil, zanzana.NewNoopClient(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, dashSrv, ac, b)
|
||||
require.NoError(t, err)
|
||||
@@ -1506,6 +1506,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
|
||||
nil,
|
||||
zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash
|
||||
nil,
|
||||
zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
dashboard, err := service.SaveDashboard(context.Background(), dashItem, true)
|
||||
@@ -400,6 +401,7 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, zanzana.NewNoopClient(),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, svcErr)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashboardService)
|
||||
@@ -461,7 +463,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
|
||||
cfg, dashboardStore, folderStore,
|
||||
features, folderPermissions, dashboardPermissions, ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, zanzana.NewNoopClient(), nil,
|
||||
nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(t, dashSvcErr)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashService)
|
||||
|
||||
@@ -736,7 +736,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash
|
||||
cfg, dashboardStore, folderStore,
|
||||
featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, zanzana.NewNoopClient(), nil,
|
||||
nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
dashboard, err := service.SaveDashboard(context.Background(), dashItem, true)
|
||||
@@ -833,7 +833,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
|
||||
cfg, dashStore, folderStore,
|
||||
features, acmock.NewMockedPermissionsService(), dashPermissionService, ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, zanzana.NewNoopClient(), nil,
|
||||
nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashService)
|
||||
|
||||
@@ -62,7 +62,7 @@ func SetupDashboardService(tb testing.TB, sqlStore db.DB, fs *folderimpl.Dashboa
|
||||
cfg, dashboardStore, fs,
|
||||
features, folderPermissions, dashboardPermissions, ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, zanzana.NewNoopClient(), nil,
|
||||
nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(tb, err)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ func (du *DashboardUpdater) syncPluginDashboards(ctx context.Context, plugin plu
|
||||
if dash.Removed {
|
||||
du.logger.Info("Deleting plugin dashboard", "pluginId", plugin.ID, "dashboard", dash.Slug)
|
||||
|
||||
if err := du.dashboardService.DeleteDashboard(ctx, dash.DashboardId, orgID); err != nil {
|
||||
if err := du.dashboardService.DeleteDashboard(ctx, dash.DashboardId, dash.UID, orgID); err != nil {
|
||||
du.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
|
||||
return
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func (du *DashboardUpdater) handlePluginStateChanged(ctx context.Context, event
|
||||
|
||||
for _, dash := range queryResult {
|
||||
du.logger.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug)
|
||||
if err := du.dashboardService.DeleteDashboard(ctx, dash.ID, dash.OrgID); err != nil {
|
||||
if err := du.dashboardService.DeleteDashboard(ctx, dash.ID, dash.UID, dash.OrgID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,18 +443,21 @@ func (s *pluginsSettingsServiceMock) DecryptedValues(_ *pluginsettings.DTO) map[
|
||||
type dashboardServiceMock struct {
|
||||
dashboards.DashboardService
|
||||
deleteDashboardArgs []struct {
|
||||
orgId int64
|
||||
dashboardId int64
|
||||
orgId int64
|
||||
dashboardId int64
|
||||
dashboardUID string
|
||||
}
|
||||
}
|
||||
|
||||
func (s *dashboardServiceMock) DeleteDashboard(_ context.Context, dashboardId int64, orgId int64) error {
|
||||
func (s *dashboardServiceMock) DeleteDashboard(_ context.Context, dashboardId int64, dashboardUID string, orgId int64) error {
|
||||
s.deleteDashboardArgs = append(s.deleteDashboardArgs, struct {
|
||||
orgId int64
|
||||
dashboardId int64
|
||||
orgId int64
|
||||
dashboardId int64
|
||||
dashboardUID string
|
||||
}{
|
||||
orgId: orgId,
|
||||
dashboardId: dashboardId,
|
||||
orgId: orgId,
|
||||
dashboardId: dashboardId,
|
||||
dashboardUID: dashboardUID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -532,8 +535,9 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar
|
||||
|
||||
sCtx.dashboardService = &dashboardServiceMock{
|
||||
deleteDashboardArgs: []struct {
|
||||
orgId int64
|
||||
dashboardId int64
|
||||
orgId int64
|
||||
dashboardId int64
|
||||
dashboardUID string
|
||||
}{},
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T)
|
||||
dashService, err := service.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStoreService, folderStore,
|
||||
featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ var (
|
||||
defaultPreinstallPlugins = map[string]InstallPlugin{
|
||||
// Default preinstalled plugins
|
||||
"grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""},
|
||||
"grafana-pyroscope-app": {"grafana-pyroscope-app", "", ""},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestIntegrationSimpleQuery(t *testing.T) {
|
||||
require.Error(t, err, "expecting a 400")
|
||||
require.JSONEq(t, `{
|
||||
"results": {
|
||||
"": {
|
||||
"A": {
|
||||
"error": "[sse.dependencyError] did not execute expression [Y] due to a failure to of the dependent expression or query [X]",
|
||||
"status": 400,
|
||||
"errorSource": ""
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@ import debounce from 'debounce-promise';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { DashboardSearchItem } from 'app/features/search/types';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
@@ -55,17 +57,36 @@ export const DashboardPicker = ({
|
||||
(async () => {
|
||||
// value was manually changed from outside or we are rendering for the first time.
|
||||
// We need to fetch dashboard information.
|
||||
const res = await getDashboardAPI().getDashboardDTO(value);
|
||||
if (res.dashboard) {
|
||||
const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI;
|
||||
if (isUIReadyForV2) {
|
||||
// When using getDashboardAPI, if isUIReadyForV2 is true, we will pass `v2` prop
|
||||
// That will return a dashboard response using schema v2. We only ask for `v2` when the component is ready to process the new shape
|
||||
const resWithSchemaV2 = await getDashboardAPI('v2').getDashboardDTO(value, undefined);
|
||||
|
||||
setCurrent({
|
||||
value: {
|
||||
uid: res.dashboard.uid,
|
||||
title: res.dashboard.title,
|
||||
folderTitle: res.meta.folderTitle,
|
||||
folderUid: res.meta.folderUid,
|
||||
uid: resWithSchemaV2.metadata.name,
|
||||
title: resWithSchemaV2.spec.title,
|
||||
folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle],
|
||||
folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder],
|
||||
},
|
||||
label: formatLabel(res.meta?.folderTitle, res.dashboard.title),
|
||||
label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title),
|
||||
});
|
||||
} else {
|
||||
// when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version
|
||||
const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined);
|
||||
|
||||
if (resWithSchemaV1.dashboard) {
|
||||
setCurrent({
|
||||
value: {
|
||||
uid: resWithSchemaV1.dashboard.uid,
|
||||
title: resWithSchemaV1.dashboard.title,
|
||||
folderTitle: resWithSchemaV1.meta.folderTitle,
|
||||
folderUid: resWithSchemaV1.meta.folderUid,
|
||||
},
|
||||
label: formatLabel(resWithSchemaV1.meta?.folderTitle, resWithSchemaV1.dashboard.title),
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
// we don't need to rerun this effect every time `current` changes
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ResourceClient,
|
||||
ObjectMeta,
|
||||
K8sAPIGroupList,
|
||||
AnnoKeySavedFromUI,
|
||||
} from './types';
|
||||
|
||||
export interface GroupVersionResource {
|
||||
@@ -73,7 +74,7 @@ function setSavedFromUIAnnotation(meta: Partial<ObjectMeta>) {
|
||||
if (!meta.annotations) {
|
||||
meta.annotations = {};
|
||||
}
|
||||
meta.annotations['grafana.app/saved-from-ui'] = config.buildInfo.versionString;
|
||||
meta.annotations[AnnoKeySavedFromUI] = config.buildInfo.versionString;
|
||||
}
|
||||
|
||||
export class DatasourceAPIVersions {
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface ObjectMeta {
|
||||
// The first time this was saved
|
||||
creationTimestamp: string;
|
||||
// General resource annotations -- including the common grafana.app values
|
||||
annotations?: GrafanaAnnotations;
|
||||
annotations?: GrafanaAnnotations & GrafanaClientAnnotations;
|
||||
// General application level key+value pairs
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export const AnnoKeyCreatedBy = 'grafana.app/createdBy';
|
||||
export const AnnoKeyUpdatedTimestamp = 'grafana.app/updatedTimestamp';
|
||||
export const AnnoKeyUpdatedBy = 'grafana.app/updatedBy';
|
||||
export const AnnoKeyFolder = 'grafana.app/folder';
|
||||
export const AnnoKeyFolderTitle = 'grafana.app/folderTitle';
|
||||
export const AnnoKeyFolderId = 'grafana.app/folderId';
|
||||
export const AnnoKeyFolderUrl = 'grafana.app/folderUrl';
|
||||
export const AnnoKeyMessage = 'grafana.app/message';
|
||||
export const AnnoKeySlug = 'grafana.app/slug';
|
||||
|
||||
@@ -40,8 +43,11 @@ export const AnnoKeySlug = 'grafana.app/slug';
|
||||
export const AnnoKeyRepoName = 'grafana.app/repoName';
|
||||
export const AnnoKeyRepoPath = 'grafana.app/repoPath';
|
||||
export const AnnoKeyRepoHash = 'grafana.app/repoHash';
|
||||
const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp';
|
||||
export const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp';
|
||||
|
||||
export const AnnoKeySavedFromUI = 'grafana.app/saved-from-ui';
|
||||
|
||||
// Annotations provided by the API
|
||||
type GrafanaAnnotations = {
|
||||
[AnnoKeyCreatedBy]?: string;
|
||||
[AnnoKeyUpdatedTimestamp]?: string;
|
||||
@@ -53,9 +59,16 @@ type GrafanaAnnotations = {
|
||||
[AnnoKeyRepoPath]?: string;
|
||||
[AnnoKeyRepoHash]?: string;
|
||||
[AnnoKeyRepoTimestamp]?: string;
|
||||
};
|
||||
|
||||
// Any key value
|
||||
[key: string]: string | undefined;
|
||||
// Annotations provided by the front-end client
|
||||
type GrafanaClientAnnotations = {
|
||||
[AnnoKeyMessage]?: string;
|
||||
[AnnoKeyFolderTitle]?: string;
|
||||
[AnnoKeyFolderUrl]?: string;
|
||||
[AnnoKeyFolderId]?: number;
|
||||
[AnnoKeyFolderId]?: number;
|
||||
[AnnoKeySavedFromUI]?: string;
|
||||
};
|
||||
|
||||
export interface Resource<T = object, S = object, K = string> extends TypeMeta<K> {
|
||||
|
||||
+3
-3
@@ -86,7 +86,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"description": "Test Description",
|
||||
"editable": true,
|
||||
"elements": {
|
||||
"test-panel-uid": {
|
||||
"panel-1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"data": {
|
||||
@@ -98,6 +98,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
},
|
||||
},
|
||||
"description": "Test Description",
|
||||
"id": 1,
|
||||
"links": [
|
||||
{
|
||||
"targetBlank": true,
|
||||
@@ -110,7 +111,6 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
},
|
||||
],
|
||||
"title": "Test Panel",
|
||||
"uid": "test-panel-uid",
|
||||
"vizConfig": {
|
||||
"kind": "timeseries",
|
||||
"spec": {
|
||||
@@ -134,7 +134,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "test-panel-uid",
|
||||
"name": "panel-1",
|
||||
},
|
||||
"height": 0,
|
||||
"width": 0,
|
||||
|
||||
+30
-2
@@ -1,7 +1,7 @@
|
||||
import { DataSourceApi, PluginType, VariableSupportType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel';
|
||||
import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from './buildNewDashboardSaveModel';
|
||||
|
||||
const fakeDsMock: DataSourceApi = {
|
||||
name: 'fake-std',
|
||||
@@ -58,7 +58,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('buildNewDashboardSaveModel', () => {
|
||||
describe('buildNewDashboardSaveModelV1', () => {
|
||||
it('should not have template variables defined by default', async () => {
|
||||
const result = await buildNewDashboardSaveModel();
|
||||
expect(result.dashboard.templating).toBeUndefined();
|
||||
@@ -85,3 +85,31 @@ describe('buildNewDashboardSaveModel', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNewDashboardSaveModelV2', () => {
|
||||
it('should not have template variables defined by default', async () => {
|
||||
const result = await buildNewDashboardSaveModelV2();
|
||||
expect(result.spec.variables).toEqual([]);
|
||||
});
|
||||
|
||||
describe('when featureToggles.newDashboardWithFiltersAndGroupBy is true', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.newDashboardWithFiltersAndGroupBy = true;
|
||||
});
|
||||
afterAll(() => {
|
||||
config.featureToggles.newDashboardWithFiltersAndGroupBy = false;
|
||||
});
|
||||
|
||||
it('should add filter and group by variables if the datasource supports it and is set as default', async () => {
|
||||
const result = await buildNewDashboardSaveModelV2();
|
||||
expect(result.spec.variables).toHaveLength(2);
|
||||
expect(result.spec.variables[0].kind).toBe('AdhocVariable');
|
||||
expect(result.spec.variables[1].kind).toBe('GroupByVariable');
|
||||
});
|
||||
|
||||
it("should set the new dashboard's timezone to the user's timezone", async () => {
|
||||
const result = await buildNewDashboardSaveModelV2();
|
||||
expect(result.spec.timeSettings.timezone).toEqual('Africa/Abidjan');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { VariableModel, defaultDashboard } from '@grafana/schema';
|
||||
import {
|
||||
AdhocVariableKind,
|
||||
DashboardV2Spec,
|
||||
defaultAdhocVariableSpec,
|
||||
defaultDashboardV2Spec,
|
||||
defaultGroupByVariableSpec,
|
||||
defaultTimeSettingsSpec,
|
||||
GroupByVariableKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { AnnoKeyFolder } from 'app/features/apiserver/types';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
|
||||
@@ -62,3 +73,75 @@ export async function buildNewDashboardSaveModel(urlFolderUid?: string): Promise
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function buildNewDashboardSaveModelV2(
|
||||
urlFolderUid?: string
|
||||
): Promise<DashboardWithAccessInfo<DashboardV2Spec>> {
|
||||
let variablesList = defaultDashboardV2Spec().variables;
|
||||
|
||||
if (config.featureToggles.newDashboardWithFiltersAndGroupBy) {
|
||||
// Add filter and group by variables if the datasource supports it
|
||||
const defaultDs = await getDatasourceSrv().get();
|
||||
|
||||
if (defaultDs.getTagKeys) {
|
||||
const datasourceRef = {
|
||||
type: defaultDs.meta.id,
|
||||
uid: defaultDs.uid,
|
||||
};
|
||||
|
||||
const filterVariable: AdhocVariableKind = {
|
||||
kind: 'AdhocVariable',
|
||||
spec: { ...defaultAdhocVariableSpec(), name: 'Filter', datasource: datasourceRef },
|
||||
};
|
||||
|
||||
const groupByVariable: GroupByVariableKind = {
|
||||
kind: 'GroupByVariable',
|
||||
spec: {
|
||||
...defaultGroupByVariableSpec(),
|
||||
datasource: datasourceRef,
|
||||
name: 'Group by',
|
||||
},
|
||||
};
|
||||
|
||||
variablesList = (variablesList || []).concat([filterVariable, groupByVariable]);
|
||||
}
|
||||
}
|
||||
|
||||
const data: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
apiVersion: 'v2alpha0',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
spec: {
|
||||
...defaultDashboardV2Spec(),
|
||||
title: 'New dashboard',
|
||||
timeSettings: {
|
||||
...defaultTimeSettingsSpec(),
|
||||
timezone: config.bootData.user?.timezone || defaultTimeSettingsSpec().timezone,
|
||||
},
|
||||
},
|
||||
access: {
|
||||
canStar: false,
|
||||
canShare: false,
|
||||
canDelete: false,
|
||||
// Not sure this should belong here or to metadata.annotations
|
||||
isNew: true,
|
||||
},
|
||||
metadata: {
|
||||
name: '',
|
||||
resourceVersion: '0',
|
||||
creationTimestamp: '0',
|
||||
annotations: {
|
||||
[AnnoKeyFolder]: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (variablesList) {
|
||||
data.spec.variables = variablesList;
|
||||
}
|
||||
|
||||
if (urlFolderUid && data.metadata.annotations) {
|
||||
data.metadata.annotations[AnnoKeyFolder] = urlFolderUid;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
+6
-6
@@ -26,7 +26,7 @@ import {
|
||||
TextVariableKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/examples';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
@@ -213,7 +213,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
// Layout
|
||||
const layout = scene.state.body as DefaultGridLayoutManager;
|
||||
expect(layout.state.grid.state.children.length).toBe(1);
|
||||
expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${Object.keys(dash.elements)[0]}`);
|
||||
expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${dash.elements['panel-1'].spec.id}`);
|
||||
const gridLayoutItemSpec = dash.layout.spec.items[0].spec;
|
||||
expect(layout.state.grid.state.children[0].state.width).toBe(gridLayoutItemSpec.width);
|
||||
expect(layout.state.grid.state.children[0].state.height).toBe(gridLayoutItemSpec.height);
|
||||
@@ -222,13 +222,13 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
|
||||
// Transformations
|
||||
expect((vizPanel.state.$data as SceneDataTransformer)?.state.transformations[0]).toEqual(
|
||||
dash.elements['test-panel-uid'].spec.data.spec.transformations[0].spec
|
||||
dash.elements['panel-1'].spec.data.spec.transformations[0].spec
|
||||
);
|
||||
});
|
||||
|
||||
it('should set panel ds if it is mixed DS', () => {
|
||||
const dashboard = cloneDeep(defaultDashboard);
|
||||
dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({
|
||||
dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
@@ -256,7 +256,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
|
||||
it('should set panel ds as undefined if it is not mixed DS', () => {
|
||||
const dashboard = cloneDeep(defaultDashboard);
|
||||
dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({
|
||||
dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
@@ -284,7 +284,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
it('should set panel ds as mixed if one ds is undefined', () => {
|
||||
const dashboard = cloneDeep(defaultDashboard);
|
||||
|
||||
dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({
|
||||
dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
|
||||
+5
-6
@@ -51,7 +51,7 @@ import {
|
||||
QueryVariableKind,
|
||||
TextVariableKind,
|
||||
} from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
|
||||
@@ -72,7 +72,7 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
|
||||
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
|
||||
import { getDashboardSceneFor, getIntervalsFromQueryString } from '../utils/utils';
|
||||
import { getDashboardSceneFor, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils';
|
||||
|
||||
import { SnapshotVariable } from './custom-variables/SnapshotVariable';
|
||||
import { registerPanelInteractionsReporter } from './transformSaveModelToScene';
|
||||
@@ -194,7 +194,7 @@ function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridIte
|
||||
const vizPanel = buildVizPanel(panel);
|
||||
|
||||
return new DashboardGridItem({
|
||||
key: `grid-item-${panel.spec.uid}`,
|
||||
key: `grid-item-${panel.spec.id}`,
|
||||
x: element.spec.x,
|
||||
y: element.spec.y,
|
||||
width: element.spec.width,
|
||||
@@ -231,7 +231,7 @@ function buildVizPanel(panel: PanelKind): VizPanel {
|
||||
const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride;
|
||||
|
||||
const vizPanelState: VizPanelState = {
|
||||
key: panel.spec.uid,
|
||||
key: getVizPanelKeyForPanelId(panel.spec.id),
|
||||
title: panel.spec.title,
|
||||
description: panel.spec.description,
|
||||
pluginId: panel.spec.vizConfig.kind,
|
||||
@@ -341,8 +341,7 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider
|
||||
queryCachingTTL: panel.data.spec.queryOptions.queryCachingTTL,
|
||||
minInterval: panel.data.spec.queryOptions.interval ?? undefined,
|
||||
dataLayerFilter: {
|
||||
// FIXME: This is asking for a number as panel ID but here the uid of a panel is string
|
||||
panelId: Number.isNaN(parseInt(panel.uid, 10)) ? 0 : parseInt(panel.uid, 10),
|
||||
panelId: panel.id,
|
||||
},
|
||||
$behaviors: [new DashboardDatasourceBehaviour({})],
|
||||
});
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ describe('transformSceneToSaveModelSchemaV2', () => {
|
||||
children: [
|
||||
new DashboardGridItem({
|
||||
body: new VizPanel({
|
||||
key: 'test-panel-uid',
|
||||
key: 'panel-1',
|
||||
pluginId: 'timeseries',
|
||||
title: 'Test Panel',
|
||||
titleItems: [
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { getQueryRunnerFor } from '../utils/utils';
|
||||
import { getPanelIdForVizPanel, getQueryRunnerFor, getVizPanelKeyForPanelId } from '../utils/utils';
|
||||
|
||||
import { sceneVariablesSetToSchemaV2Variables } from './sceneVariablesSetToVariables';
|
||||
import { transformCursorSynctoEnum } from './transformToV2TypesUtils';
|
||||
@@ -214,7 +214,7 @@ function getElements(state: DashboardSceneState) {
|
||||
const elementSpec: PanelKind = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
uid: vizPanel.state.key ?? '', // FIXME: why is key optional?
|
||||
id: getPanelIdForVizPanel(vizPanel),
|
||||
title: vizPanel.state.title,
|
||||
description: vizPanel.state.description ?? '',
|
||||
links: getPanelLinks(vizPanel),
|
||||
@@ -354,7 +354,7 @@ function getVizPanelQueryOptions(vizPanel: VizPanel): QueryOptionsSpec {
|
||||
function createElements(panels: PanelKind[]): Record<string, PanelKind> {
|
||||
return panels.reduce(
|
||||
(acc, panel) => {
|
||||
const key = panel.spec.uid;
|
||||
const key = getVizPanelKeyForPanelId(panel.spec.id);
|
||||
acc[key] = panel;
|
||||
return acc;
|
||||
},
|
||||
|
||||
@@ -31,15 +31,17 @@ describe('ShareMenu', () => {
|
||||
Object.defineProperty(contextSrv, 'isSignedIn', {
|
||||
value: true,
|
||||
});
|
||||
grantUserPermissions([AccessControlAction.SnapshotsCreate]);
|
||||
grantUserPermissions([AccessControlAction.SnapshotsCreate, AccessControlAction.OrgUsersAdd]);
|
||||
|
||||
config.publicDashboardsEnabled = true;
|
||||
config.snapshotEnabled = true;
|
||||
config.externalUserMngLinkUrl = 'http://localhost:3000';
|
||||
setup({ meta: { canEdit: true } });
|
||||
|
||||
expect(await screen.findByTestId(selector.shareInternally)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId(selector.shareSnapshot)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId(selector.inviteUser)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not share externally when public dashboard is disabled', async () => {
|
||||
@@ -49,6 +51,24 @@ describe('ShareMenu', () => {
|
||||
expect(screen.queryByTestId(selector.shareExternally)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render invite user when user does not have access', async () => {
|
||||
Object.defineProperty(contextSrv, 'isSignedIn', {
|
||||
value: true,
|
||||
});
|
||||
|
||||
expect(await screen.queryByTestId(selector.inviteUser)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render invite user when externalUserMngLinkUrl is not provided', async () => {
|
||||
Object.defineProperty(contextSrv, 'isSignedIn', {
|
||||
value: true,
|
||||
});
|
||||
grantUserPermissions([AccessControlAction.OrgUsersAdd]);
|
||||
config.externalUserMngLinkUrl = '';
|
||||
|
||||
expect(await screen.queryByTestId(selector.inviteUser)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('ShareSnapshot', () => {
|
||||
it('should not share snapshot when user is not signed in', async () => {
|
||||
config.snapshotEnabled = true;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { IconName, Menu } from '@grafana/ui';
|
||||
import { Icon, IconName, Menu, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@@ -23,6 +26,9 @@ export interface ShareDrawerMenuItem {
|
||||
icon: IconName;
|
||||
renderCondition: boolean;
|
||||
onClick: (d: DashboardScene) => void;
|
||||
renderDividerAbove?: boolean;
|
||||
component?: React.ComponentType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let customShareDrawerItems: ShareDrawerMenuItem[] = [];
|
||||
@@ -36,6 +42,7 @@ export function resetDashboardShareDrawerItems() {
|
||||
}
|
||||
|
||||
export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardScene; panel?: VizPanel }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const onMenuItemClick = (shareView: string) => {
|
||||
locationService.partial({ shareView });
|
||||
};
|
||||
@@ -63,8 +70,6 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
},
|
||||
});
|
||||
|
||||
customShareDrawerItems.forEach((d) => menuItems.push(d));
|
||||
|
||||
menuItems.push({
|
||||
shareId: shareDashboardType.snapshot,
|
||||
testId: newShareButtonSelector.shareSnapshot,
|
||||
@@ -79,8 +84,24 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
},
|
||||
});
|
||||
|
||||
customShareDrawerItems.forEach((d) => menuItems.push(d));
|
||||
|
||||
menuItems.push({
|
||||
shareId: shareDashboardType.inviteUser,
|
||||
testId: newShareButtonSelector.inviteUser,
|
||||
icon: 'add-user',
|
||||
label: t('share-dashboard.menu.invite-user-title', 'Invite new member'),
|
||||
renderCondition: !!config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd),
|
||||
onClick: () => {
|
||||
window.open(config.externalUserMngLinkUrl, '_blank');
|
||||
},
|
||||
renderDividerAbove: true,
|
||||
component: () => <Icon name="external-link-alt" className={styles.inviteUserItemIcon} />,
|
||||
className: styles.inviteUserItem,
|
||||
});
|
||||
|
||||
return menuItems.filter((item) => item.renderCondition);
|
||||
}, [panel]);
|
||||
}, [panel, styles]);
|
||||
|
||||
const onClick = (item: ShareDrawerMenuItem) => {
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
@@ -94,15 +115,33 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
return (
|
||||
<Menu data-testid={newShareButtonSelector.container}>
|
||||
{buildMenuItems().map((item) => (
|
||||
<Menu.Item
|
||||
key={item.shareId}
|
||||
testId={item.testId}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
description={item.description}
|
||||
onClick={() => onClick(item)}
|
||||
/>
|
||||
<React.Fragment key={item.shareId}>
|
||||
{item.renderDividerAbove && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
testId={item.testId}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
description={item.description}
|
||||
component={item.component}
|
||||
className={item.className}
|
||||
onClick={() => onClick(item)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
inviteUserItem: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
inviteUserItemIcon: css({
|
||||
color: theme.colors.text.link,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alp
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { VizPanelLinks } from '../scene/PanelLinks';
|
||||
import { TypedVariableModelV2 } from '../serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { getQueryRunnerFor } from '../utils/utils';
|
||||
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
||||
|
||||
type SceneVariableConstructor<T extends SceneVariableState, V extends SceneVariable<T>> = new (
|
||||
initialState: Partial<T>
|
||||
@@ -60,19 +60,19 @@ export function validateVariable<
|
||||
}
|
||||
|
||||
export function validateVizPanel(vizPanel: VizPanel, dash: DashboardV2Spec) {
|
||||
expect(vizPanel.state.title).toBe(dash.elements['test-panel-uid'].spec.title);
|
||||
expect(vizPanel.state.description).toBe(dash.elements['test-panel-uid'].spec.description);
|
||||
expect(vizPanel.state.pluginId).toBe(dash.elements['test-panel-uid'].spec.vizConfig.kind);
|
||||
expect(vizPanel.state.pluginVersion).toBe(dash.elements['test-panel-uid'].spec.vizConfig.spec.pluginVersion);
|
||||
expect(vizPanel.state.options).toEqual(dash.elements['test-panel-uid'].spec.vizConfig.spec.options);
|
||||
expect(vizPanel.state.fieldConfig).toEqual(dash.elements['test-panel-uid'].spec.vizConfig.spec.fieldConfig);
|
||||
expect(vizPanel.state.key).toBe(dash.elements['test-panel-uid'].spec.uid);
|
||||
expect(vizPanel.state.displayMode).toBe(dash.elements['test-panel-uid'].spec.transparent ? 'transparent' : 'default');
|
||||
expect(vizPanel.state.title).toBe(dash.elements['panel-1'].spec.title);
|
||||
expect(vizPanel.state.description).toBe(dash.elements['panel-1'].spec.description);
|
||||
expect(vizPanel.state.pluginId).toBe(dash.elements['panel-1'].spec.vizConfig.kind);
|
||||
expect(vizPanel.state.pluginVersion).toBe(dash.elements['panel-1'].spec.vizConfig.spec.pluginVersion);
|
||||
expect(vizPanel.state.options).toEqual(dash.elements['panel-1'].spec.vizConfig.spec.options);
|
||||
expect(vizPanel.state.fieldConfig).toEqual(dash.elements['panel-1'].spec.vizConfig.spec.fieldConfig);
|
||||
expect(getPanelIdForVizPanel(vizPanel)).toBe(dash.elements['panel-1'].spec.id);
|
||||
expect(vizPanel.state.displayMode).toBe(dash.elements['panel-1'].spec.transparent ? 'transparent' : 'default');
|
||||
|
||||
expect(vizPanel.state.$data).toBeInstanceOf(SceneDataTransformer);
|
||||
const dataTransformer = vizPanel.state.$data as SceneDataTransformer;
|
||||
expect(dataTransformer.state.transformations[0]).toEqual(
|
||||
dash.elements['test-panel-uid'].spec.data.spec.transformations[0].spec
|
||||
dash.elements['panel-1'].spec.data.spec.transformations[0].spec
|
||||
);
|
||||
|
||||
expect(dataTransformer.state.$data).toBeInstanceOf(SceneQueryRunner);
|
||||
@@ -87,9 +87,7 @@ export function validateVizPanel(vizPanel: VizPanel, dash: DashboardV2Spec) {
|
||||
expect(queryRunner.state.minInterval).toBe('1m');
|
||||
const titleItems = vizPanel.state.titleItems as SceneObject[];
|
||||
const vizPanelLinks = titleItems[0] as VizPanelLinks;
|
||||
expect(vizPanelLinks.state.rawLinks).toHaveLength(dash.elements['test-panel-uid'].spec.links.length);
|
||||
expect(vizPanelLinks.state.rawLinks).toEqual(dash.elements['test-panel-uid'].spec.links);
|
||||
// FIXME: This is asking for a number as panel ID but here the uid of a panel is string
|
||||
// will be fixed once scenes package is updated to support string panel ID
|
||||
// expect(queryRunner.state.dataLayerFilter?.panelId).toBe(0);
|
||||
expect(vizPanelLinks.state.rawLinks).toHaveLength(dash.elements['panel-1'].spec.links.length);
|
||||
expect(vizPanelLinks.state.rawLinks).toEqual(dash.elements['panel-1'].spec.links);
|
||||
expect(queryRunner.state.dataLayerFilter?.panelId).toBe(dash.elements['panel-1'].spec.id);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
import { LegacyDashboardAPI } from './legacy';
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types';
|
||||
import { getDashboardsApiVersion } from './utils';
|
||||
import { K8sDashboardAPI } from './v0';
|
||||
import { K8sDashboardV2APIStub } from './v2';
|
||||
|
||||
// Implemented using /api/dashboards/*
|
||||
class LegacyDashboardAPI implements DashboardAPI {
|
||||
constructor() {}
|
||||
type DashboardAPIClients = {
|
||||
legacy: DashboardAPI<DashboardDTO>;
|
||||
v0: DashboardAPI<DashboardDTO>;
|
||||
// v1: DashboardDTO; TODO[schema]: enable v1 when available
|
||||
v2: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>>;
|
||||
};
|
||||
|
||||
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> {
|
||||
dashboardWatcher.ignoreNextSave();
|
||||
type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>;
|
||||
|
||||
return getBackendSrv().post<SaveDashboardResponseDTO>('/api/dashboards/db/', {
|
||||
dashboard: options.dashboard,
|
||||
message: options.message ?? '',
|
||||
overwrite: options.overwrite ?? false,
|
||||
folderUid: options.folderUid,
|
||||
});
|
||||
}
|
||||
let clients: Partial<DashboardAPIClients> | undefined;
|
||||
|
||||
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {
|
||||
return getBackendSrv().delete<DeleteDashboardResponse>(`/api/dashboards/uid/${uid}`, { showSuccessAlert });
|
||||
}
|
||||
|
||||
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<DashboardDTO> {
|
||||
return getBackendSrv().get<DashboardDTO>(`/api/dashboards/uid/${uid}`, params);
|
||||
}
|
||||
}
|
||||
|
||||
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: '',
|
||||
};
|
||||
}
|
||||
|
||||
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): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
export function setDashboardAPI(override: Partial<DashboardAPIClients> | undefined) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('dashboardAPI can be only overridden in test environment');
|
||||
}
|
||||
instance = override;
|
||||
clients = override;
|
||||
}
|
||||
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
if (v === 'v2' && requestV2Response === 'v2') {
|
||||
return new K8sDashboardV2APIStub(false);
|
||||
}
|
||||
|
||||
if (!clients[v]) {
|
||||
throw new Error(`Unknown Dashboard API version: ${v}`);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -180,4 +180,5 @@ export const shareDashboardType: {
|
||||
pdf: 'pdf',
|
||||
report: 'report',
|
||||
publicDashboard: 'public_dashboard',
|
||||
inviteUser: 'invite_user',
|
||||
};
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpda
|
||||
value={inputState.value}
|
||||
className={styles.input}
|
||||
data-testid="scopes-tree-search"
|
||||
escapeRegex={false}
|
||||
onChange={(value) => {
|
||||
setInputState({ value, isDirty: true });
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { DataFrameView } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
@@ -33,6 +35,14 @@ describe('SearchStateManager', () => {
|
||||
view: new DataFrameView<DashboardQueryResult>({ fields: [], length: 0 }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Can get search state manager with initial state', async () => {
|
||||
const stm = getSearchStateManager();
|
||||
expect(stm.state.layout).toBe(SearchLayout.Folders);
|
||||
@@ -115,9 +125,9 @@ describe('SearchStateManager', () => {
|
||||
|
||||
stm.onQueryChange('debugging');
|
||||
|
||||
await wait(150);
|
||||
jest.advanceTimersByTime(150);
|
||||
|
||||
expect(stm.state.result?.totalRows).toEqual(10);
|
||||
await waitFor(() => expect(stm.state.result?.totalRows).toEqual(10));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/schema": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/google-sdk": "0.1.2",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/schema": "11.5.0-pre",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/sql": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/schema": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "workspace:*",
|
||||
"@grafana/e2e-selectors": "workspace:*",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/o11y-ds-frontend": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/sql": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/sql": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
|
||||
@@ -349,7 +349,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
grafana_version: config.buildInfo.version,
|
||||
query: queryValue ?? '',
|
||||
});
|
||||
subQueries.push(this.handleTraceQlMetricsQuery(options));
|
||||
subQueries.push(this.handleTraceQlMetricsQuery(options, targets.traceql));
|
||||
} else {
|
||||
reportInteraction('grafana_traces_traceql_queried', {
|
||||
datasourceType: 'tempo',
|
||||
@@ -588,8 +588,11 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
}
|
||||
};
|
||||
|
||||
handleTraceQlMetricsQuery = (options: DataQueryRequest<TempoQuery>): Observable<DataQueryResponse> => {
|
||||
const validTargets = options.targets
|
||||
handleTraceQlMetricsQuery(
|
||||
options: DataQueryRequest<TempoQuery>,
|
||||
targets: TempoQuery[]
|
||||
): Observable<DataQueryResponse> {
|
||||
const validTargets = targets
|
||||
.filter((t) => t.query)
|
||||
.map(
|
||||
(t): TempoQuery => ({ ...t, query: this.applyVariables(t, options.scopedVars).query, queryType: 'traceql' })
|
||||
@@ -607,7 +610,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
handleMetricsSummaryQuery = (target: TempoQuery, query: string, options: DataQueryRequest<TempoQuery>) => {
|
||||
reportInteraction('grafana_traces_metrics_summary_queried', {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "workspace:*",
|
||||
"@grafana/e2e-selectors": "workspace:*",
|
||||
"@grafana/experimental": "2.1.5",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/lezer-logql": "0.2.6",
|
||||
"@grafana/lezer-traceql": "0.0.20",
|
||||
"@grafana/monaco-logql": "^0.0.7",
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user