Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley committed 2024-12-18 08:39:03 +03:00
commit b322f65243
118 files changed
+2693 -772

No files matched your search

+6 -1
View File
@@ -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": [
@@ -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;
}
}
+2
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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)
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+1 -1
View File
@@ -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)
}
+5 -5
View File
@@ -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)
+51 -23
View File
@@ -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
}
+9 -2
View File
@@ -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)
+1 -1
View File
@@ -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()
+7
View File
@@ -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",
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
221 alertingUIOptimizeReducer GA @grafana/alerting-squad false false true
222 azureMonitorEnableUserAuth GA @grafana/partner-datasources false false false
223 alertingNotificationsStepMode experimental @grafana/alerting-squad false false true
224 useV2DashboardsAPI experimental @grafana/dashboards-squad false true false
225 feedbackButton experimental @grafana/grafana-operator-experience-squad false false false
226 elasticsearchCrossClusterSearch preview @grafana/aws-datasources false false false
227 unifiedHistory experimental @grafana/grafana-frontend-platform false false true
+4
View File
@@ -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"
+13
View File
@@ -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)
+1 -1
View File
@@ -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)
+1
View File
@@ -30,6 +30,7 @@ var (
defaultPreinstallPlugins = map[string]InstallPlugin{
// Default preinstalled plugins
"grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""},
"grafana-pyroscope-app": {"grafana-pyroscope-app", "", ""},
}
)
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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 {
+17 -4
View File
@@ -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> {
@@ -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,
@@ -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;
}
@@ -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',
@@ -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({})],
});
@@ -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: [
@@ -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');
});
});
+109
View File
@@ -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');
});
});
+68
View File
@@ -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